Initial commit: VesiscanBasic Android app
Port of MedilightV2 iOS app to Android with Kotlin + Jetpack Compose. Includes BLE connectivity, NIRS/Piezo sensor modes, Rive animations, interactive measurement history charts, and foldable device support. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@@ -0,0 +1,11 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle
|
||||||
|
/local.properties
|
||||||
|
/.idea/
|
||||||
|
.DS_Store
|
||||||
|
/build
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
|
local.properties
|
||||||
|
app/build/
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/build
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "com.example.medilightv2android"
|
||||||
|
compileSdk {
|
||||||
|
version = release(36)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "com.example.medilightv2android"
|
||||||
|
minSdk = 24
|
||||||
|
targetSdk = 36
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "11"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.compose.ui)
|
||||||
|
implementation(libs.androidx.compose.ui.graphics)
|
||||||
|
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.compose.material3)
|
||||||
|
implementation(libs.androidx.compose.material.icons)
|
||||||
|
implementation(libs.androidx.compose.foundation)
|
||||||
|
implementation(libs.gson)
|
||||||
|
implementation(libs.androidx.security.crypto)
|
||||||
|
implementation(libs.rive.android)
|
||||||
|
implementation(libs.androidx.startup.runtime)
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
androidTestImplementation(libs.androidx.junit)
|
||||||
|
androidTestImplementation(libs.androidx.espresso.core)
|
||||||
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
|
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
|
}
|
||||||
@@ -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 com.example.medilightv2android
|
||||||
|
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* See [testing documentation](http://d.android.com/tools/testing).
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class ExampleInstrumentedTest {
|
||||||
|
@Test
|
||||||
|
fun useAppContext() {
|
||||||
|
// Context of the app under test.
|
||||||
|
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
assertEquals("com.example.medilightv2android", appContext.packageName)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- BLE Permissions -->
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" android:maxSdkVersion="30" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||||
|
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
|
<!-- Notification Permission (Android 13+) -->
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<!-- BLE feature -->
|
||||||
|
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.Medilightv2android">
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|density"
|
||||||
|
android:resizeableActivity="true"
|
||||||
|
android:theme="@style/Theme.Medilightv2android">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package com.example.medilightv2android
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import com.example.medilightv2android.ble.BleManager
|
||||||
|
import com.example.medilightv2android.managers.NirsManager
|
||||||
|
import com.example.medilightv2android.models.*
|
||||||
|
import com.example.medilightv2android.services.*
|
||||||
|
|
||||||
|
enum class AppScreen {
|
||||||
|
ONBOARDING, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
|
||||||
|
NIRS_CALIBRATION, PIEZO_PERSONALIZATION, PIN_SETUP, PIN_ENTRY,
|
||||||
|
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppState(private val context: Context) {
|
||||||
|
var currentScreen by mutableStateOf(AppScreen.ONBOARDING)
|
||||||
|
var user by mutableStateOf(User())
|
||||||
|
var sensorMode by mutableStateOf(SensorMode.PIEZO)
|
||||||
|
var bladderLevel by mutableStateOf(BladderLevel())
|
||||||
|
var isDeviceConnected by mutableStateOf(false)
|
||||||
|
var piezoSettings by mutableStateOf(PiezoSettings())
|
||||||
|
|
||||||
|
private val storage = UserStorage.getInstance(context)
|
||||||
|
private val historyStore = MeasurementHistoryStore.getInstance(context)
|
||||||
|
private val notificationService = NotificationService.getInstance(context)
|
||||||
|
private val bleManager = BleManager.getInstance(context)
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadState()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadState() {
|
||||||
|
user = storage.user
|
||||||
|
sensorMode = storage.sensorMode
|
||||||
|
bladderLevel = bladderLevel.copy(
|
||||||
|
currentLevel = storage.bladderLevel,
|
||||||
|
catheterizationThreshold = storage.catheterizationLevel
|
||||||
|
)
|
||||||
|
piezoSettings = storage.piezoSettings
|
||||||
|
|
||||||
|
currentScreen = when {
|
||||||
|
!storage.hasCompletedOnboarding -> AppScreen.ONBOARDING
|
||||||
|
!storage.isRegistered -> AppScreen.REGISTRATION
|
||||||
|
!storage.hasPin -> AppScreen.PIN_SETUP
|
||||||
|
else -> AppScreen.PIN_ENTRY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun completeOnboarding() {
|
||||||
|
storage.hasCompletedOnboarding = true
|
||||||
|
currentScreen = AppScreen.REGISTRATION
|
||||||
|
}
|
||||||
|
|
||||||
|
fun completeRegistration() {
|
||||||
|
storage.user = user
|
||||||
|
storage.isRegistered = true
|
||||||
|
currentScreen = AppScreen.PIN_SETUP
|
||||||
|
}
|
||||||
|
|
||||||
|
fun goHome() {
|
||||||
|
NirsManager.instance.stopStreaming()
|
||||||
|
NirsManager.instance.reset()
|
||||||
|
bleManager.disconnect()
|
||||||
|
isDeviceConnected = false
|
||||||
|
currentScreen = AppScreen.HOME
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startFromHome() {
|
||||||
|
currentScreen = AppScreen.SENSOR_SELECT
|
||||||
|
}
|
||||||
|
|
||||||
|
fun selectSensor(mode: SensorMode) {
|
||||||
|
sensorMode = mode
|
||||||
|
storage.sensorMode = mode
|
||||||
|
currentScreen = AppScreen.DEVICE_SCAN
|
||||||
|
}
|
||||||
|
|
||||||
|
fun backToSensorSelect() {
|
||||||
|
currentScreen = AppScreen.SENSOR_SELECT
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deviceConnected() {
|
||||||
|
isDeviceConnected = true
|
||||||
|
currentScreen = if (sensorMode == SensorMode.NIRS) {
|
||||||
|
AppScreen.NIRS_CALIBRATION
|
||||||
|
} else {
|
||||||
|
AppScreen.PIEZO_PERSONALIZATION
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calibrationComplete() {
|
||||||
|
currentScreen = AppScreen.VIVAMYO_MONITORING
|
||||||
|
}
|
||||||
|
|
||||||
|
fun personalizationComplete() {
|
||||||
|
storage.piezoSettings = piezoSettings
|
||||||
|
currentScreen = AppScreen.PIEZO_MONITORING
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pinSet(pin: String) {
|
||||||
|
storage.pin = pin
|
||||||
|
storage.hasPin = true
|
||||||
|
currentScreen = AppScreen.HOME
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pinVerified() {
|
||||||
|
currentScreen = AppScreen.HOME
|
||||||
|
}
|
||||||
|
|
||||||
|
fun incrementLevel() {
|
||||||
|
val previousUrgency = bladderLevel.urgency
|
||||||
|
val newLevel = bladderLevel.copy()
|
||||||
|
newLevel.increment()
|
||||||
|
bladderLevel = newLevel
|
||||||
|
storage.bladderLevel = bladderLevel.currentLevel
|
||||||
|
|
||||||
|
val record = MeasurementRecord(
|
||||||
|
eventType = MeasurementEventType.MEASUREMENT,
|
||||||
|
level = bladderLevel.currentLevel,
|
||||||
|
estimatedVolume = estimatedVolume,
|
||||||
|
urgencyLevel = bladderLevel.urgency,
|
||||||
|
fillPercentage = fillPercentage
|
||||||
|
)
|
||||||
|
historyStore.addRecord(record)
|
||||||
|
|
||||||
|
val newUrgency = bladderLevel.urgency
|
||||||
|
if (newUrgency != previousUrgency) {
|
||||||
|
notificationService.sendUrgencyNotification(newUrgency, estimatedVolume)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetLevel() {
|
||||||
|
val record = MeasurementRecord(
|
||||||
|
eventType = MeasurementEventType.CATHETERIZATION,
|
||||||
|
level = bladderLevel.currentLevel,
|
||||||
|
estimatedVolume = estimatedVolume,
|
||||||
|
urgencyLevel = bladderLevel.urgency,
|
||||||
|
fillPercentage = fillPercentage
|
||||||
|
)
|
||||||
|
historyStore.addRecord(record)
|
||||||
|
|
||||||
|
val newLevel = bladderLevel.copy()
|
||||||
|
newLevel.reset()
|
||||||
|
bladderLevel = newLevel
|
||||||
|
storage.bladderLevel = 0
|
||||||
|
notificationService.cancelAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun showHistory() {
|
||||||
|
currentScreen = AppScreen.MEASUREMENT_HISTORY
|
||||||
|
}
|
||||||
|
|
||||||
|
fun backToMonitoring() {
|
||||||
|
currentScreen = if (sensorMode == SensorMode.NIRS) AppScreen.VIVAMYO_MONITORING else AppScreen.PIEZO_MONITORING
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateCatheterizationThreshold(threshold: Int) {
|
||||||
|
bladderLevel = bladderLevel.copy(catheterizationThreshold = threshold)
|
||||||
|
storage.catheterizationLevel = threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
val estimatedVolume: Int
|
||||||
|
get() {
|
||||||
|
if (BladderLevel.MAX_LEVEL <= 0) return 0
|
||||||
|
return (bladderLevel.currentLevel.toDouble() / BladderLevel.MAX_LEVEL * piezoSettings.maxVolume).toInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
val fillPercentage: Double
|
||||||
|
get() {
|
||||||
|
if (BladderLevel.MAX_LEVEL <= 0) return 0.0
|
||||||
|
return bladderLevel.currentLevel.toDouble() / BladderLevel.MAX_LEVEL * 100.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.example.medilightv2android
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.animation.Crossfade
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import app.rive.runtime.kotlin.core.Rive
|
||||||
|
import com.example.medilightv2android.services.KeychainService
|
||||||
|
import com.example.medilightv2android.services.NotificationService
|
||||||
|
import com.example.medilightv2android.ui.theme.Medilightv2androidTheme
|
||||||
|
import com.example.medilightv2android.ui.views.calibration.CalibrationView
|
||||||
|
import com.example.medilightv2android.ui.views.connection.DeviceScanView
|
||||||
|
import com.example.medilightv2android.ui.views.history.MeasurementHistoryView
|
||||||
|
import com.example.medilightv2android.ui.views.home.HomeView
|
||||||
|
import com.example.medilightv2android.ui.views.monitoring.PiezoMonitoringView
|
||||||
|
import com.example.medilightv2android.ui.views.monitoring.VivamyoMonitoringView
|
||||||
|
import com.example.medilightv2android.ui.views.onboarding.OnboardingView
|
||||||
|
import com.example.medilightv2android.ui.views.personalization.PiezoPersonalizationView
|
||||||
|
import com.example.medilightv2android.ui.views.pin.PinMode
|
||||||
|
import com.example.medilightv2android.ui.views.pin.PinView
|
||||||
|
import com.example.medilightv2android.ui.views.registration.RegistrationView
|
||||||
|
import com.example.medilightv2android.ui.views.sensorselect.SensorSelectView
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Initialize services
|
||||||
|
Rive.init(this)
|
||||||
|
KeychainService.init(this)
|
||||||
|
NotificationService.getInstance(this)
|
||||||
|
|
||||||
|
enableEdgeToEdge()
|
||||||
|
setContent {
|
||||||
|
Medilightv2androidTheme {
|
||||||
|
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
||||||
|
MedilightApp(modifier = Modifier.padding(innerPadding))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MedilightApp(modifier: Modifier = Modifier) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val appState = remember { AppState(context) }
|
||||||
|
|
||||||
|
Crossfade(
|
||||||
|
targetState = appState.currentScreen,
|
||||||
|
animationSpec = tween(300),
|
||||||
|
modifier = modifier,
|
||||||
|
label = "screen"
|
||||||
|
) { screen ->
|
||||||
|
when (screen) {
|
||||||
|
AppScreen.ONBOARDING -> OnboardingView(appState)
|
||||||
|
AppScreen.REGISTRATION -> RegistrationView(appState)
|
||||||
|
AppScreen.HOME -> HomeView(appState)
|
||||||
|
AppScreen.SENSOR_SELECT -> SensorSelectView(appState)
|
||||||
|
AppScreen.DEVICE_SCAN -> DeviceScanView(appState)
|
||||||
|
AppScreen.NIRS_CALIBRATION -> CalibrationView(appState)
|
||||||
|
AppScreen.PIEZO_PERSONALIZATION -> PiezoPersonalizationView(appState)
|
||||||
|
AppScreen.PIN_SETUP -> PinView(appState, PinMode.SETUP, context)
|
||||||
|
AppScreen.PIN_ENTRY -> PinView(appState, PinMode.ENTER, context)
|
||||||
|
AppScreen.VIVAMYO_MONITORING -> VivamyoMonitoringView(appState)
|
||||||
|
AppScreen.PIEZO_MONITORING -> PiezoMonitoringView(appState)
|
||||||
|
AppScreen.MEASUREMENT_HISTORY -> MeasurementHistoryView(appState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,462 @@
|
|||||||
|
package com.example.medilightv2android.ble
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.bluetooth.*
|
||||||
|
import android.bluetooth.le.*
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class BleDevice(
|
||||||
|
val device: BluetoothDevice,
|
||||||
|
var rssi: Int,
|
||||||
|
var isConnecting: Boolean = false,
|
||||||
|
var isConnected: Boolean = false
|
||||||
|
) {
|
||||||
|
val name: String @SuppressLint("MissingPermission") get() = try { device.name ?: "Unknown" } catch (_: Exception) { "Unknown" }
|
||||||
|
val address: String get() = device.address
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressLint("MissingPermission")
|
||||||
|
class BleManager private constructor(private val context: Context) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "BleManager"
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var instance: BleManager? = null
|
||||||
|
fun getInstance(context: Context): BleManager =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: BleManager(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
val SERVICE_UUID: UUID = UUID.fromString("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
|
||||||
|
val TX_CHAR_UUID: UUID = UUID.fromString("6E400002-B5A3-F393-E0A9-E50E24DCCA9E")
|
||||||
|
val RX_CHAR_UUID: UUID = UUID.fromString("6E400003-B5A3-F393-E0A9-E50E24DCCA9E")
|
||||||
|
val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
|
||||||
|
private val bluetoothAdapter: BluetoothAdapter? = bluetoothManager.adapter
|
||||||
|
private var bluetoothLeScanner: BluetoothLeScanner? = null
|
||||||
|
private var bluetoothGatt: BluetoothGatt? = null
|
||||||
|
private var txCharacteristic: BluetoothGattCharacteristic? = null
|
||||||
|
private var rxCharacteristic: BluetoothGattCharacteristic? = null
|
||||||
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
private var scanTimer: Runnable? = null
|
||||||
|
private var batteryTimer: Runnable? = null
|
||||||
|
private var connectionTimer: Runnable? = null
|
||||||
|
private var isUserDisconnect = false
|
||||||
|
|
||||||
|
val discoveredDevices = mutableStateListOf<BleDevice>()
|
||||||
|
val isScanning = mutableStateOf(false)
|
||||||
|
val isConnected = mutableStateOf(false)
|
||||||
|
val connectedDeviceName = mutableStateOf("")
|
||||||
|
val batteryLevel = mutableStateOf(0)
|
||||||
|
val bluetoothEnabled = mutableStateOf(bluetoothAdapter?.isEnabled == true)
|
||||||
|
val isServiceReady = mutableStateOf(false)
|
||||||
|
val isReconnecting = mutableStateOf(false)
|
||||||
|
val connectionError = mutableStateOf<String?>(null)
|
||||||
|
|
||||||
|
// Callbacks
|
||||||
|
var onConnectionStateChanged: ((Boolean) -> Unit)? = null
|
||||||
|
var onNirsPowerOnReceived: (() -> Unit)? = null
|
||||||
|
var onNirsSensorActivated: (() -> Unit)? = null
|
||||||
|
var onNirsMagReceived: ((ByteArray) -> Unit)? = null
|
||||||
|
var onNirsMcjReceived: ((ByteArray) -> Unit)? = null
|
||||||
|
var onPiezoDataReceived: ((ByteArray) -> Unit)? = null
|
||||||
|
var onUnexpectedDisconnect: (() -> Unit)? = null
|
||||||
|
var onReconnectionFailed: (() -> Unit)? = null
|
||||||
|
|
||||||
|
val isBluetoothReady: Boolean get() = bluetoothAdapter?.isEnabled == true
|
||||||
|
|
||||||
|
// Scanning
|
||||||
|
fun startScan() {
|
||||||
|
if (bluetoothAdapter?.isEnabled != true) {
|
||||||
|
bluetoothEnabled.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bluetoothEnabled.value = true
|
||||||
|
discoveredDevices.clear()
|
||||||
|
isScanning.value = true
|
||||||
|
bluetoothLeScanner = bluetoothAdapter.bluetoothLeScanner
|
||||||
|
|
||||||
|
if (bluetoothLeScanner == null) {
|
||||||
|
Log.e(TAG, "BluetoothLeScanner is null")
|
||||||
|
isScanning.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val settings = ScanSettings.Builder()
|
||||||
|
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
bluetoothLeScanner?.startScan(null, settings, scanCallback)
|
||||||
|
Log.d(TAG, "BLE scan started")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to start scan: ${e.message}")
|
||||||
|
isScanning.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scanTimer?.let { handler.removeCallbacks(it) }
|
||||||
|
val timeout = Runnable { stopScan() }
|
||||||
|
scanTimer = timeout
|
||||||
|
handler.postDelayed(timeout, 15000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopScan() {
|
||||||
|
try {
|
||||||
|
bluetoothLeScanner?.stopScan(scanCallback)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to stop scan: ${e.message}")
|
||||||
|
}
|
||||||
|
isScanning.value = false
|
||||||
|
scanTimer?.let { handler.removeCallbacks(it) }
|
||||||
|
scanTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val scanCallback = object : ScanCallback() {
|
||||||
|
override fun onScanResult(callbackType: Int, result: ScanResult) {
|
||||||
|
val device = result.device
|
||||||
|
val name = try { device.name } catch (_: Exception) { null } ?: return
|
||||||
|
if (name.isEmpty()) return
|
||||||
|
|
||||||
|
handler.post {
|
||||||
|
val existing = discoveredDevices.indexOfFirst { it.address == device.address }
|
||||||
|
if (existing >= 0) {
|
||||||
|
discoveredDevices[existing] = discoveredDevices[existing].copy(rssi = result.rssi)
|
||||||
|
} else {
|
||||||
|
discoveredDevices.add(BleDevice(device, result.rssi))
|
||||||
|
Log.d(TAG, "Discovered: $name (${device.address}) rssi=${result.rssi}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onScanFailed(errorCode: Int) {
|
||||||
|
Log.e(TAG, "Scan failed with error code: $errorCode")
|
||||||
|
handler.post {
|
||||||
|
isScanning.value = false
|
||||||
|
connectionError.value = "Scan failed (error $errorCode)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection
|
||||||
|
fun connect(bleDevice: BleDevice) {
|
||||||
|
stopScan()
|
||||||
|
Log.d(TAG, "Connecting to ${bleDevice.name} (${bleDevice.address})")
|
||||||
|
|
||||||
|
// Clean up any existing GATT connection
|
||||||
|
bluetoothGatt?.let { gatt ->
|
||||||
|
Log.d(TAG, "Closing existing GATT connection")
|
||||||
|
try {
|
||||||
|
gatt.disconnect()
|
||||||
|
gatt.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Error closing old GATT: ${e.message}")
|
||||||
|
}
|
||||||
|
bluetoothGatt = null
|
||||||
|
txCharacteristic = null
|
||||||
|
rxCharacteristic = null
|
||||||
|
}
|
||||||
|
|
||||||
|
val idx = discoveredDevices.indexOfFirst { it.address == bleDevice.address }
|
||||||
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = true)
|
||||||
|
|
||||||
|
connectionError.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
bluetoothGatt = bleDevice.device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||||
|
Log.d(TAG, "connectGatt called, gatt=${bluetoothGatt != null}")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "connectGatt failed: ${e.message}")
|
||||||
|
handler.post {
|
||||||
|
connectionError.value = "Connection failed: ${e.message}"
|
||||||
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection timeout
|
||||||
|
connectionTimer?.let { handler.removeCallbacks(it) }
|
||||||
|
val timeout = Runnable {
|
||||||
|
if (!isConnected.value) {
|
||||||
|
Log.w(TAG, "Connection timeout")
|
||||||
|
bluetoothGatt?.let { gatt ->
|
||||||
|
try {
|
||||||
|
gatt.disconnect()
|
||||||
|
gatt.close()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
bluetoothGatt = null
|
||||||
|
connectionError.value = "Connection timed out. Try again."
|
||||||
|
if (idx >= 0 && idx < discoveredDevices.size) {
|
||||||
|
discoveredDevices[idx] = discoveredDevices[idx].copy(isConnecting = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
connectionTimer = timeout
|
||||||
|
handler.postDelayed(timeout, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun disconnect() {
|
||||||
|
isUserDisconnect = true
|
||||||
|
connectionTimer?.let { handler.removeCallbacks(it) }
|
||||||
|
connectionTimer = null
|
||||||
|
bluetoothGatt?.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnection
|
||||||
|
fun reconnectToLastDevice(lastDeviceAddress: String?) {
|
||||||
|
if (lastDeviceAddress == null || bluetoothAdapter?.isEnabled != true) {
|
||||||
|
onReconnectionFailed?.invoke()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up existing
|
||||||
|
bluetoothGatt?.let { gatt ->
|
||||||
|
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
|
||||||
|
bluetoothGatt = null
|
||||||
|
}
|
||||||
|
|
||||||
|
isReconnecting.value = true
|
||||||
|
val device = bluetoothAdapter.getRemoteDevice(lastDeviceAddress)
|
||||||
|
bluetoothGatt = device.connectGatt(context, false, gattCallback, BluetoothDevice.TRANSPORT_LE)
|
||||||
|
|
||||||
|
handler.postDelayed({
|
||||||
|
if (isReconnecting.value) {
|
||||||
|
isReconnecting.value = false
|
||||||
|
bluetoothGatt?.let { gatt ->
|
||||||
|
try { gatt.disconnect(); gatt.close() } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
bluetoothGatt = null
|
||||||
|
onReconnectionFailed?.invoke()
|
||||||
|
}
|
||||||
|
}, 10000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send
|
||||||
|
fun sendRaw(data: ByteArray) {
|
||||||
|
val characteristic = txCharacteristic ?: return
|
||||||
|
val gatt = bluetoothGatt ?: return
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
gatt.writeCharacteristic(characteristic, data, BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
characteristic.value = data
|
||||||
|
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
gatt.writeCharacteristic(characteristic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Piezo Commands
|
||||||
|
fun sendPiezoPowerOn(freqOption: Int, cycles: Int) {
|
||||||
|
sendRaw(CRC16.buildCommand("mpa", intArrayOf(freqOption, cycles)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendPiezoStop() {
|
||||||
|
sendRaw(CRC16.buildCommand("mps"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendBurst(freqOption: Int, cycles: Int, delayUs: Int, piezoCh: Int = 0) {
|
||||||
|
sendRaw(CRC16.buildCommand("mec", intArrayOf(freqOption, delayUs, 140, cycles, 1, piezoCh)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIRS Commands
|
||||||
|
fun sendNirsPowerOn() {
|
||||||
|
sendRaw(CRC16.buildCommand("mta", intArrayOf(1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMqqQuery() {
|
||||||
|
sendRaw(CRC16.buildCommand("mqq"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMagQuery() {
|
||||||
|
sendRaw(CRC16.buildCommand("mag"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendMcjQuery() {
|
||||||
|
sendRaw(CRC16.buildCommand("mcj"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendBatteryQuery() {
|
||||||
|
sendRaw(CRC16.buildCommand("msn", intArrayOf(0)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startBatteryPolling() {
|
||||||
|
batteryTimer?.let { handler.removeCallbacks(it) }
|
||||||
|
handler.postDelayed({ sendBatteryQuery() }, 1000)
|
||||||
|
val poll = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
sendBatteryQuery()
|
||||||
|
handler.postDelayed(this, 30000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batteryTimer = poll
|
||||||
|
handler.postDelayed(poll, 31000)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopBatteryPolling() {
|
||||||
|
batteryTimer?.let { handler.removeCallbacks(it) }
|
||||||
|
batteryTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private val gattCallback = object : BluetoothGattCallback() {
|
||||||
|
override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
|
||||||
|
Log.d(TAG, "onConnectionStateChange: status=$status newState=$newState")
|
||||||
|
handler.post {
|
||||||
|
// Cancel connection timeout
|
||||||
|
connectionTimer?.let { handler.removeCallbacks(it) }
|
||||||
|
connectionTimer = null
|
||||||
|
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS && newState != BluetoothProfile.STATE_CONNECTED) {
|
||||||
|
// Connection failed with error
|
||||||
|
Log.e(TAG, "GATT connection failed with status: $status")
|
||||||
|
bluetoothGatt = null
|
||||||
|
txCharacteristic = null
|
||||||
|
rxCharacteristic = null
|
||||||
|
isConnected.value = false
|
||||||
|
isServiceReady.value = false
|
||||||
|
isReconnecting.value = false
|
||||||
|
connectionError.value = "Connection failed (status $status). Try again."
|
||||||
|
|
||||||
|
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
||||||
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
|
||||||
|
|
||||||
|
gatt.close()
|
||||||
|
return@post
|
||||||
|
}
|
||||||
|
|
||||||
|
when (newState) {
|
||||||
|
BluetoothProfile.STATE_CONNECTED -> {
|
||||||
|
Log.d(TAG, "Connected to ${gatt.device.address}")
|
||||||
|
isReconnecting.value = false
|
||||||
|
isUserDisconnect = false
|
||||||
|
connectionError.value = null
|
||||||
|
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
||||||
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = true, isConnecting = false)
|
||||||
|
isConnected.value = true
|
||||||
|
connectedDeviceName.value = try { gatt.device.name ?: "Unknown" } catch (_: Exception) { "Unknown" }
|
||||||
|
|
||||||
|
// Save last connected device address
|
||||||
|
try {
|
||||||
|
val prefs = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
||||||
|
prefs.edit().putString("last_device_address", gatt.device.address).apply()
|
||||||
|
} catch (_: Exception) {}
|
||||||
|
|
||||||
|
gatt.discoverServices()
|
||||||
|
startBatteryPolling()
|
||||||
|
onConnectionStateChanged?.invoke(true)
|
||||||
|
}
|
||||||
|
BluetoothProfile.STATE_DISCONNECTED -> {
|
||||||
|
Log.d(TAG, "Disconnected from ${gatt.device.address}")
|
||||||
|
stopBatteryPolling()
|
||||||
|
bluetoothGatt = null
|
||||||
|
txCharacteristic = null
|
||||||
|
rxCharacteristic = null
|
||||||
|
isConnected.value = false
|
||||||
|
isServiceReady.value = false
|
||||||
|
connectedDeviceName.value = ""
|
||||||
|
batteryLevel.value = 0
|
||||||
|
|
||||||
|
val idx = discoveredDevices.indexOfFirst { it.address == gatt.device.address }
|
||||||
|
if (idx >= 0) discoveredDevices[idx] = discoveredDevices[idx].copy(isConnected = false, isConnecting = false)
|
||||||
|
|
||||||
|
onConnectionStateChanged?.invoke(false)
|
||||||
|
|
||||||
|
if (!isUserDisconnect) {
|
||||||
|
onUnexpectedDisconnect?.invoke()
|
||||||
|
}
|
||||||
|
isUserDisconnect = false
|
||||||
|
gatt.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
|
||||||
|
Log.d(TAG, "onServicesDiscovered: status=$status")
|
||||||
|
if (status != BluetoothGatt.GATT_SUCCESS) {
|
||||||
|
handler.post { connectionError.value = "Service discovery failed (status $status)" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val service = gatt.getService(SERVICE_UUID)
|
||||||
|
if (service == null) {
|
||||||
|
Log.e(TAG, "NUS service not found!")
|
||||||
|
handler.post { connectionError.value = "Device service not found" }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
txCharacteristic = service.getCharacteristic(TX_CHAR_UUID)
|
||||||
|
rxCharacteristic = service.getCharacteristic(RX_CHAR_UUID)
|
||||||
|
|
||||||
|
Log.d(TAG, "TX=${txCharacteristic != null}, RX=${rxCharacteristic != null}")
|
||||||
|
|
||||||
|
rxCharacteristic?.let { rx ->
|
||||||
|
gatt.setCharacteristicNotification(rx, true)
|
||||||
|
val descriptor = rx.getDescriptor(CCCD_UUID)
|
||||||
|
if (descriptor != null) {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
gatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
gatt.writeDescriptor(descriptor)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "CCCD descriptor not found for RX characteristic")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handler.post {
|
||||||
|
if (txCharacteristic != null && rxCharacteristic != null) {
|
||||||
|
isServiceReady.value = true
|
||||||
|
Log.d(TAG, "Service ready!")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, value: ByteArray) {
|
||||||
|
if (characteristic.uuid == RX_CHAR_UUID) {
|
||||||
|
if (value.size < 4) return
|
||||||
|
handler.post { processReceivedData(value) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in API 33", ReplaceWith("onCharacteristicChanged(gatt, characteristic, value)"))
|
||||||
|
override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
|
||||||
|
if (characteristic.uuid == RX_CHAR_UUID) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
val data = characteristic.value ?: return
|
||||||
|
if (data.size < 4) return
|
||||||
|
handler.post { processReceivedData(data) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun processReceivedData(data: ByteArray) {
|
||||||
|
val prefix = String(data, 0, minOf(4, data.size), Charsets.US_ASCII)
|
||||||
|
Log.d(TAG, "Received: $prefix (${data.size} bytes)")
|
||||||
|
when (prefix) {
|
||||||
|
"rta:", "rta!" -> onNirsPowerOnReceived?.invoke()
|
||||||
|
"rsh:", "rsh!", "rqq:", "rqq!" -> onNirsSensorActivated?.invoke()
|
||||||
|
"rag:", "rag!" -> onNirsMagReceived?.invoke(data)
|
||||||
|
"rcj:", "rcj!" -> onNirsMcjReceived?.invoke(data)
|
||||||
|
"rpa:" -> onPiezoDataReceived?.invoke(data)
|
||||||
|
"rsn:" -> {
|
||||||
|
if (data.size >= 6) {
|
||||||
|
val millivolts = ((data[4].toInt() and 0xFF) shl 8) or (data[5].toInt() and 0xFF)
|
||||||
|
batteryLevel.value = ((millivolts - 3700) * 100 / 500).coerceIn(0, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package com.example.medilightv2android.ble
|
||||||
|
|
||||||
|
object CRC16 {
|
||||||
|
private const val POLYNOMIAL: Int = 0x1021
|
||||||
|
private const val INITIAL_VALUE: Int = 0xFFFF
|
||||||
|
|
||||||
|
fun calculate(data: ByteArray): Int {
|
||||||
|
var crc = INITIAL_VALUE
|
||||||
|
for (byte in data) {
|
||||||
|
crc = crc xor ((byte.toInt() and 0xFF) shl 8)
|
||||||
|
for (j in 0 until 8) {
|
||||||
|
crc = if ((crc and 0x8000) != 0) {
|
||||||
|
(crc shl 1) xor POLYNOMIAL
|
||||||
|
} else {
|
||||||
|
crc shl 1
|
||||||
|
}
|
||||||
|
crc = crc and 0xFFFF
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crc
|
||||||
|
}
|
||||||
|
|
||||||
|
fun calculate(string: String): Int = calculate(string.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
fun appendCRC(data: ByteArray): ByteArray {
|
||||||
|
val crc = calculate(data)
|
||||||
|
return data + byteArrayOf(
|
||||||
|
(crc and 0xFF).toByte(), // Low byte first (Little Endian)
|
||||||
|
((crc shr 8) and 0xFF).toByte() // High byte
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun appendCRC(string: String): ByteArray = appendCRC(string.toByteArray(Charsets.UTF_8))
|
||||||
|
|
||||||
|
fun verify(data: ByteArray): Boolean {
|
||||||
|
if (data.size < 2) return false
|
||||||
|
val payload = data.copyOfRange(0, data.size - 2)
|
||||||
|
val receivedCRC = (data[data.size - 2].toInt() and 0xFF) or
|
||||||
|
((data[data.size - 1].toInt() and 0xFF) shl 8)
|
||||||
|
return calculate(payload) == receivedCRC
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildCommand(cmd: String, params: IntArray = intArrayOf()): ByteArray {
|
||||||
|
val cmdBytes = "$cmd?".toByteArray(Charsets.UTF_8)
|
||||||
|
val paramBytes = ByteArray(params.size * 2)
|
||||||
|
for (i in params.indices) {
|
||||||
|
paramBytes[i * 2] = (params[i] and 0xFF).toByte() // Low byte
|
||||||
|
paramBytes[i * 2 + 1] = ((params[i] shr 8) and 0xFF).toByte() // High byte
|
||||||
|
}
|
||||||
|
return appendCRC(cmdBytes + paramBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildCommandASCII(cmd: String, asciiParams: String = ""): ByteArray {
|
||||||
|
return appendCRC("$cmd?$asciiParams".toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun buildSimpleCommand(cmd: String): ByteArray {
|
||||||
|
return appendCRC(cmd.toByteArray(Charsets.UTF_8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
package com.example.medilightv2android.managers
|
||||||
|
|
||||||
|
import com.example.medilightv2android.models.NirsConstants
|
||||||
|
import com.example.medilightv2android.models.NirsMagResponse
|
||||||
|
import com.example.medilightv2android.models.NirsOxyResult
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.ln
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
class NirsCalcEngine {
|
||||||
|
|
||||||
|
// Gain Calibration
|
||||||
|
private var gainValues: IntArray = intArrayOf()
|
||||||
|
val hasGain: Boolean get() = gainValues.size >= 24
|
||||||
|
|
||||||
|
fun setGainFromMag(response: NirsMagResponse) {
|
||||||
|
gainValues = response.gainValues
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearGain() {
|
||||||
|
gainValues = intArrayOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AGC DAC -> Software Amplification
|
||||||
|
private fun agcGain(dacValue: Int): Double {
|
||||||
|
return 10.0.pow(((dacValue.toDouble() * 3.3 / 4096.0 * -80.0) + 88.0) / 20.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeGain Calculation
|
||||||
|
fun degain(rawLed: IntArray): DoubleArray {
|
||||||
|
if (rawLed.size < 24 || !hasGain) return DoubleArray(24) { 0.0 }
|
||||||
|
|
||||||
|
val result = DoubleArray(24)
|
||||||
|
for (i in 0 until 24) {
|
||||||
|
val raw = rawLed[i].toDouble()
|
||||||
|
val sag = gainValues[i]
|
||||||
|
val pkg = i / 6
|
||||||
|
val wl = i % 6
|
||||||
|
|
||||||
|
val softAmp = agcGain(sag)
|
||||||
|
val hAmp = NirsConstants.hardwareAmp[pkg]
|
||||||
|
val spResponse = NirsConstants.spectralResponse[wl]
|
||||||
|
val dpf = NirsConstants.dpf[wl]
|
||||||
|
|
||||||
|
val denominator = softAmp * hAmp
|
||||||
|
if (denominator > 0) {
|
||||||
|
var dg = raw / denominator
|
||||||
|
dg /= spResponse
|
||||||
|
dg *= dpf
|
||||||
|
result[i] = dg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Butterworth IIR Filter
|
||||||
|
private var filterState: Array<DoubleArray> = emptyArray()
|
||||||
|
private var filterInitialized = false
|
||||||
|
var isFilterEnabled = true
|
||||||
|
|
||||||
|
fun resetFilter() {
|
||||||
|
filterState = Array(24) { doubleArrayOf(0.0, 0.0, 0.0, 0.0) }
|
||||||
|
filterInitialized = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyFilter(x: Double, state: DoubleArray): Double {
|
||||||
|
val b = NirsConstants.filterB
|
||||||
|
val a = NirsConstants.filterA
|
||||||
|
|
||||||
|
val y = b[0] * x + b[1] * state[0] + b[2] * state[1] - a[1] * state[2] - a[2] * state[3]
|
||||||
|
|
||||||
|
state[1] = state[0]
|
||||||
|
state[0] = x
|
||||||
|
state[3] = state[2]
|
||||||
|
state[2] = y
|
||||||
|
|
||||||
|
return y
|
||||||
|
}
|
||||||
|
|
||||||
|
fun filterDeGain(degainValues: DoubleArray): DoubleArray {
|
||||||
|
if (degainValues.size < 24) return degainValues
|
||||||
|
|
||||||
|
if (filterState.isEmpty()) resetFilter()
|
||||||
|
|
||||||
|
if (!filterInitialized) {
|
||||||
|
for (i in 0 until 24) {
|
||||||
|
val v = degainValues[i]
|
||||||
|
filterState[i] = doubleArrayOf(v, v, v, v)
|
||||||
|
}
|
||||||
|
filterInitialized = true
|
||||||
|
}
|
||||||
|
|
||||||
|
val filtered = DoubleArray(24)
|
||||||
|
for (i in 0 until 24) {
|
||||||
|
filtered[i] = applyFilter(degainValues[i], filterState[i])
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// Oxygenation Calculation
|
||||||
|
fun calcOxygenation(degainValues: DoubleArray): NirsOxyResult {
|
||||||
|
if (degainValues.size < 24) return NirsOxyResult.zero
|
||||||
|
|
||||||
|
val c = NirsConstants
|
||||||
|
val muA = DoubleArray(c.numWavelengths)
|
||||||
|
|
||||||
|
for (i in 0 until c.numWavelengths) {
|
||||||
|
val i2cm = degainValues[i]
|
||||||
|
val i5cm = degainValues[18 + i]
|
||||||
|
|
||||||
|
val i2norm = i2cm / (c.ledPower[0] / c.referencePower)
|
||||||
|
val i5norm = i5cm / (c.ledPower[3] / c.referencePower)
|
||||||
|
|
||||||
|
val mix = c.srsMixNear * i2norm + c.srsMixFar * i5norm
|
||||||
|
|
||||||
|
if (mix > 0) {
|
||||||
|
val slope = -ln(mix) / c.srsPathLength
|
||||||
|
muA[i] = -slope
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val solution = solveNormalEq6x3(muA)
|
||||||
|
|
||||||
|
var hbO2 = max(0.0, solution.first) * c.oxyScale
|
||||||
|
var hb = max(0.0, solution.second) * c.oxyScale
|
||||||
|
|
||||||
|
val tHb = hbO2 + hb
|
||||||
|
val stO2 = if (tHb > 0) (hbO2 / tHb) * 100.0 else 0.0
|
||||||
|
|
||||||
|
return NirsOxyResult(hbO2 = hbO2, hb = hb, tHb = tHb, stO2 = stO2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Least Squares Solver (6x3)
|
||||||
|
private fun solveNormalEq6x3(muA: DoubleArray): Triple<Double, Double, Double> {
|
||||||
|
val eps = arrayOf(
|
||||||
|
NirsConstants.extinctionHbO2,
|
||||||
|
NirsConstants.extinctionHb,
|
||||||
|
NirsConstants.extinctionH2O
|
||||||
|
)
|
||||||
|
|
||||||
|
val n = Array(3) { DoubleArray(3) }
|
||||||
|
for (i in 0 until 3) {
|
||||||
|
for (j in 0 until 3) {
|
||||||
|
var sum = 0.0
|
||||||
|
for (k in 0 until 6) sum += eps[i][k] * eps[j][k]
|
||||||
|
n[i][j] = sum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val b = DoubleArray(3)
|
||||||
|
for (i in 0 until 3) {
|
||||||
|
var sum = 0.0
|
||||||
|
for (k in 0 until 6) sum += eps[i][k] * muA[k]
|
||||||
|
b[i] = sum
|
||||||
|
}
|
||||||
|
|
||||||
|
val det = n[0][0] * (n[1][1] * n[2][2] - n[1][2] * n[2][1]) -
|
||||||
|
n[0][1] * (n[1][0] * n[2][2] - n[1][2] * n[2][0]) +
|
||||||
|
n[0][2] * (n[1][0] * n[2][1] - n[1][1] * n[2][0])
|
||||||
|
|
||||||
|
if (abs(det) < 1e-12) return Triple(0.0, 0.0, 0.0)
|
||||||
|
|
||||||
|
val inv = Array(3) { DoubleArray(3) }
|
||||||
|
inv[0][0] = (n[1][1] * n[2][2] - n[1][2] * n[2][1]) / det
|
||||||
|
inv[0][1] = -(n[0][1] * n[2][2] - n[0][2] * n[2][1]) / det
|
||||||
|
inv[0][2] = (n[0][1] * n[1][2] - n[0][2] * n[1][1]) / det
|
||||||
|
inv[1][0] = -(n[1][0] * n[2][2] - n[1][2] * n[2][0]) / det
|
||||||
|
inv[1][1] = (n[0][0] * n[2][2] - n[0][2] * n[2][0]) / det
|
||||||
|
inv[1][2] = -(n[0][0] * n[1][2] - n[0][2] * n[1][0]) / det
|
||||||
|
inv[2][0] = (n[1][0] * n[2][1] - n[1][1] * n[2][0]) / det
|
||||||
|
inv[2][1] = -(n[0][0] * n[2][1] - n[0][1] * n[2][0]) / det
|
||||||
|
inv[2][2] = (n[0][0] * n[1][1] - n[0][1] * n[1][0]) / det
|
||||||
|
|
||||||
|
val c = DoubleArray(3) { i ->
|
||||||
|
inv[i][0] * b[0] + inv[i][1] * b[1] + inv[i][2] * b[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
return Triple(c[0], c[1], c[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full Processing Pipeline
|
||||||
|
data class ProcessResult(
|
||||||
|
val degain: DoubleArray,
|
||||||
|
val degainFiltered: DoubleArray,
|
||||||
|
val oxy: NirsOxyResult,
|
||||||
|
val oxyFiltered: NirsOxyResult
|
||||||
|
)
|
||||||
|
|
||||||
|
fun process(rawLed: IntArray): ProcessResult {
|
||||||
|
val dg = degain(rawLed)
|
||||||
|
val oxyRaw = calcOxygenation(dg)
|
||||||
|
|
||||||
|
val dgf: DoubleArray
|
||||||
|
val oxyF: NirsOxyResult
|
||||||
|
if (isFilterEnabled) {
|
||||||
|
dgf = filterDeGain(dg)
|
||||||
|
oxyF = calcOxygenation(dgf)
|
||||||
|
} else {
|
||||||
|
dgf = dg
|
||||||
|
oxyF = oxyRaw
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProcessResult(dg, dgf, oxyRaw, oxyF)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package com.example.medilightv2android.managers
|
||||||
|
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import com.example.medilightv2android.models.NirsMagResponse
|
||||||
|
import com.example.medilightv2android.models.NirsMcjPacket
|
||||||
|
import com.example.medilightv2android.models.NirsConstants
|
||||||
|
import com.example.medilightv2android.models.NirsOxyResult
|
||||||
|
|
||||||
|
class NirsManager private constructor() {
|
||||||
|
companion object {
|
||||||
|
val instance: NirsManager by lazy { NirsManager() }
|
||||||
|
}
|
||||||
|
|
||||||
|
// State
|
||||||
|
val isStreaming = mutableStateOf(false)
|
||||||
|
val isCalibrated = mutableStateOf(false)
|
||||||
|
val isFilterEnabled = mutableStateOf(true)
|
||||||
|
|
||||||
|
// Current Values
|
||||||
|
val currentOxy = mutableStateOf(NirsOxyResult.zero)
|
||||||
|
val sampleCount = mutableIntStateOf(0)
|
||||||
|
|
||||||
|
// Chart History
|
||||||
|
val oxyHbO2History = mutableStateListOf<Double>()
|
||||||
|
val oxyHbHistory = mutableStateListOf<Double>()
|
||||||
|
|
||||||
|
// Internal
|
||||||
|
val calcEngine = NirsCalcEngine()
|
||||||
|
private val maxPoints = NirsConstants.maxHistoryPoints
|
||||||
|
|
||||||
|
// Calibration
|
||||||
|
fun setCalibrationGain(response: NirsMagResponse) {
|
||||||
|
calcEngine.setGainFromMag(response)
|
||||||
|
isCalibrated.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process mcj Packet
|
||||||
|
fun processMcjPacket(packet: NirsMcjPacket) {
|
||||||
|
val result = calcEngine.process(packet.ledValues)
|
||||||
|
currentOxy.value = result.oxyFiltered
|
||||||
|
sampleCount.intValue++
|
||||||
|
|
||||||
|
appendTrimming(oxyHbO2History, result.oxyFiltered.hbO2)
|
||||||
|
appendTrimming(oxyHbHistory, result.oxyFiltered.hb)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
fun toggleFilter() {
|
||||||
|
isFilterEnabled.value = !isFilterEnabled.value
|
||||||
|
calcEngine.isFilterEnabled = isFilterEnabled.value
|
||||||
|
if (isFilterEnabled.value) calcEngine.resetFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearData() {
|
||||||
|
oxyHbO2History.clear()
|
||||||
|
oxyHbHistory.clear()
|
||||||
|
currentOxy.value = NirsOxyResult.zero
|
||||||
|
sampleCount.intValue = 0
|
||||||
|
calcEngine.resetFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startStreaming() {
|
||||||
|
isStreaming.value = true
|
||||||
|
calcEngine.resetFilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopStreaming() {
|
||||||
|
isStreaming.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
stopStreaming()
|
||||||
|
clearData()
|
||||||
|
isCalibrated.value = false
|
||||||
|
calcEngine.clearGain()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
private fun appendTrimming(list: MutableList<Double>, value: Double) {
|
||||||
|
list.add(value)
|
||||||
|
while (list.size > maxPoints) list.removeAt(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.example.medilightv2android.models
|
||||||
|
|
||||||
|
data class BladderLevel(
|
||||||
|
var currentLevel: Int = 0,
|
||||||
|
var catheterizationThreshold: Int = 8
|
||||||
|
) {
|
||||||
|
val urgency: UrgencyLevel
|
||||||
|
get() = when {
|
||||||
|
currentLevel >= catheterizationThreshold -> UrgencyLevel.URGENT
|
||||||
|
currentLevel == catheterizationThreshold - 1 -> UrgencyLevel.WARNING
|
||||||
|
else -> UrgencyLevel.SAFE
|
||||||
|
}
|
||||||
|
|
||||||
|
val progress: Double
|
||||||
|
get() = currentLevel.toDouble() / MAX_LEVEL.toDouble()
|
||||||
|
|
||||||
|
fun increment() {
|
||||||
|
if (currentLevel < MAX_LEVEL) currentLevel++
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
currentLevel = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MAX_LEVEL = 10
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.example.medilightv2android.models
|
||||||
|
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
enum class MeasurementEventType {
|
||||||
|
MEASUREMENT, CATHETERIZATION
|
||||||
|
}
|
||||||
|
|
||||||
|
data class MeasurementRecord(
|
||||||
|
val id: String = UUID.randomUUID().toString(),
|
||||||
|
val timestamp: Long = System.currentTimeMillis(),
|
||||||
|
val eventType: MeasurementEventType,
|
||||||
|
val level: Int,
|
||||||
|
val estimatedVolume: Int,
|
||||||
|
val urgency: String,
|
||||||
|
val fillPercentage: Double
|
||||||
|
) {
|
||||||
|
constructor(
|
||||||
|
eventType: MeasurementEventType,
|
||||||
|
level: Int,
|
||||||
|
estimatedVolume: Int,
|
||||||
|
urgencyLevel: UrgencyLevel,
|
||||||
|
fillPercentage: Double
|
||||||
|
) : this(
|
||||||
|
eventType = eventType,
|
||||||
|
level = level,
|
||||||
|
estimatedVolume = estimatedVolume,
|
||||||
|
urgency = urgencyLevel.rawStringValue,
|
||||||
|
fillPercentage = fillPercentage
|
||||||
|
)
|
||||||
|
|
||||||
|
val urgencyLevel: UrgencyLevel
|
||||||
|
get() = UrgencyLevel.fromString(urgency)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.example.medilightv2android.models
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
object NirsConstants {
|
||||||
|
// Hardware Parameters
|
||||||
|
val hardwareAmp = doubleArrayOf(1.2, 5.008, 11.22, 37.072)
|
||||||
|
val ledPower = doubleArrayOf(5.0, 10.0, 15.0, 20.0)
|
||||||
|
val spectralResponse = doubleArrayOf(0.457, 0.561, 0.602, 0.653, 0.71, 0.725)
|
||||||
|
val dpf = doubleArrayOf(7.0, 5.8, 5.5, 5.2, 4.9, 4.7)
|
||||||
|
|
||||||
|
// Extinction Coefficients
|
||||||
|
val extinctionHbO2 = doubleArrayOf(0.303, 0.586, 0.816, 1.058, 1.224, 1.162)
|
||||||
|
val extinctionHb = doubleArrayOf(2.968, 1.549, 0.762, 0.691, 0.777, 0.429)
|
||||||
|
val extinctionH2O = doubleArrayOf(0.003, 0.026, 0.020, 0.044, 0.267, 0.448)
|
||||||
|
|
||||||
|
// Butterworth IIR Filter (2nd order, Fc=1.5Hz, Fs=4Hz)
|
||||||
|
val filterB = doubleArrayOf(0.4208, 0.8416, 0.4208)
|
||||||
|
val filterA = doubleArrayOf(1.0, 0.3533, 0.3300)
|
||||||
|
|
||||||
|
// SRS Parameters
|
||||||
|
const val srsMixNear = 0.8
|
||||||
|
const val srsMixFar = 0.2
|
||||||
|
const val srsPathLength = 2.6
|
||||||
|
const val referencePower = 20.0
|
||||||
|
const val oxyScale = 22.0
|
||||||
|
|
||||||
|
// Dimensions
|
||||||
|
const val numPackages = 4
|
||||||
|
const val numWavelengths = 6
|
||||||
|
const val numChannels = numPackages * numWavelengths
|
||||||
|
|
||||||
|
// Wavelength Definitions
|
||||||
|
val wavelengths = intArrayOf(665, 760, 800, 850, 920, 970)
|
||||||
|
val wavelengthNames = arrayOf("665nm", "760nm", "800nm", "850nm", "920nm", "970nm")
|
||||||
|
|
||||||
|
val wavelengthColors = listOf(
|
||||||
|
Color.Red,
|
||||||
|
Color.Blue,
|
||||||
|
Color(0xFF33CC33),
|
||||||
|
Color(0xFFFF9500),
|
||||||
|
Color(0xFF9966FF),
|
||||||
|
Color(0xFF666666)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chart History
|
||||||
|
const val maxHistoryPoints = 240
|
||||||
|
const val streamingIntervalMs = 250L
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
package com.example.medilightv2android.models
|
||||||
|
|
||||||
|
data class NirsMcjPacket(
|
||||||
|
val battery: Int,
|
||||||
|
val temperature: Int,
|
||||||
|
val accX: Short,
|
||||||
|
val accY: Short,
|
||||||
|
val accZ: Short,
|
||||||
|
val gyrX: Short,
|
||||||
|
val gyrY: Short,
|
||||||
|
val gyrZ: Short,
|
||||||
|
val ledValues: IntArray
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun parse(data: ByteArray): NirsMcjPacket? {
|
||||||
|
if (data.size < 72) return null
|
||||||
|
|
||||||
|
fun u16(offset: Int): Int =
|
||||||
|
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||||
|
|
||||||
|
fun s16(offset: Int): Short =
|
||||||
|
u16(offset).toShort()
|
||||||
|
|
||||||
|
val base = 4
|
||||||
|
val battery = u16(base)
|
||||||
|
val temperature = u16(base + 2)
|
||||||
|
val accX = s16(base + 4)
|
||||||
|
val accY = s16(base + 6)
|
||||||
|
val accZ = s16(base + 8)
|
||||||
|
val gyrX = s16(base + 10)
|
||||||
|
val gyrY = s16(base + 12)
|
||||||
|
val gyrZ = s16(base + 14)
|
||||||
|
|
||||||
|
val ledValues = IntArray(24)
|
||||||
|
for (i in 0 until 24) {
|
||||||
|
val offset = base + 20 + (i * 2)
|
||||||
|
if (offset + 1 >= data.size) return null
|
||||||
|
ledValues[i] = u16(offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
return NirsMcjPacket(
|
||||||
|
battery, temperature,
|
||||||
|
accX, accY, accZ,
|
||||||
|
gyrX, gyrY, gyrZ,
|
||||||
|
ledValues
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is NirsMcjPacket) return false
|
||||||
|
return battery == other.battery && ledValues.contentEquals(other.ledValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int = battery * 31 + ledValues.contentHashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class NirsMagResponse(val gainValues: IntArray) {
|
||||||
|
companion object {
|
||||||
|
fun parse(data: ByteArray): NirsMagResponse? {
|
||||||
|
if (data.size < 52) return null
|
||||||
|
val base = 4
|
||||||
|
val values = IntArray(24) { i ->
|
||||||
|
val offset = base + (i * 2)
|
||||||
|
((data[offset].toInt() and 0xFF) shl 8) or (data[offset + 1].toInt() and 0xFF)
|
||||||
|
}
|
||||||
|
return NirsMagResponse(values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) return true
|
||||||
|
if (other !is NirsMagResponse) return false
|
||||||
|
return gainValues.contentEquals(other.gainValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int = gainValues.contentHashCode()
|
||||||
|
}
|
||||||
|
|
||||||
|
data class NirsOxyResult(
|
||||||
|
val hbO2: Double = 0.0,
|
||||||
|
val hb: Double = 0.0,
|
||||||
|
val tHb: Double = 0.0,
|
||||||
|
val stO2: Double = 0.0
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val zero = NirsOxyResult()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.example.medilightv2android.models
|
||||||
|
|
||||||
|
enum class Posture(val displayName: String) {
|
||||||
|
STANDING("Standing"),
|
||||||
|
SITTING("Sitting"),
|
||||||
|
LYING("Lying Down")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class SensorPosition(val displayName: String, val description: String) {
|
||||||
|
LOWER_ABDOMEN("Lower Abdomen", "Below the navel"),
|
||||||
|
SUPRAPUBIC("Suprapubic", "Above the pubic bone")
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PiezoSettings(
|
||||||
|
var posture: Posture = Posture.SITTING,
|
||||||
|
var sensorPosition: SensorPosition = SensorPosition.LOWER_ABDOMEN,
|
||||||
|
var maxVolume: Int = 500
|
||||||
|
)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.example.medilightv2android.models
|
||||||
|
|
||||||
|
enum class SensorMode(val displayName: String) {
|
||||||
|
NIRS("NIRS"),
|
||||||
|
PIEZO("Piezo")
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.example.medilightv2android.models
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
enum class UrgencyLevel {
|
||||||
|
SAFE, WARNING, URGENT;
|
||||||
|
|
||||||
|
val backgroundColor: Color
|
||||||
|
get() = when (this) {
|
||||||
|
SAFE -> Color(0xFFEDF8FF)
|
||||||
|
WARNING -> Color(0xFFFFFAE6)
|
||||||
|
URGENT -> Color(0xFFFFEBEB)
|
||||||
|
}
|
||||||
|
|
||||||
|
val accentColor: Color
|
||||||
|
get() = when (this) {
|
||||||
|
SAFE -> Color(0xFF008FFF)
|
||||||
|
WARNING -> Color(0xFFF5A623)
|
||||||
|
URGENT -> Color(0xFFF04523)
|
||||||
|
}
|
||||||
|
|
||||||
|
val gradientColors: List<Color>
|
||||||
|
get() = when (this) {
|
||||||
|
SAFE -> listOf(Color(0xFF00DBA1), Color(0xFF008FFF))
|
||||||
|
WARNING -> listOf(Color(0xFFFECC2E), Color(0xFFF04A23))
|
||||||
|
URGENT -> listOf(Color(0xFFF04A23), Color(0xFFCC1A1A))
|
||||||
|
}
|
||||||
|
|
||||||
|
val message: String
|
||||||
|
get() = when (this) {
|
||||||
|
SAFE -> "Enjoy your day!"
|
||||||
|
WARNING -> "Prepare for catheterization"
|
||||||
|
URGENT -> "Please perform catheterization"
|
||||||
|
}
|
||||||
|
|
||||||
|
val rawStringValue: String
|
||||||
|
get() = when (this) {
|
||||||
|
SAFE -> "safe"
|
||||||
|
WARNING -> "warning"
|
||||||
|
URGENT -> "urgent"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromString(value: String): UrgencyLevel = when (value) {
|
||||||
|
"warning" -> WARNING
|
||||||
|
"urgent" -> URGENT
|
||||||
|
else -> SAFE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.example.medilightv2android.models
|
||||||
|
|
||||||
|
data class User(
|
||||||
|
var firstName: String = "",
|
||||||
|
var lastName: String = "",
|
||||||
|
var age: Int = 0,
|
||||||
|
var heightCm: Double = 0.0,
|
||||||
|
var weightKg: Double = 0.0
|
||||||
|
)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.example.medilightv2android.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import androidx.security.crypto.EncryptedSharedPreferences
|
||||||
|
import androidx.security.crypto.MasterKey
|
||||||
|
|
||||||
|
object KeychainService {
|
||||||
|
private const val FILE_NAME = "medilightv2_secure_prefs"
|
||||||
|
|
||||||
|
private var prefs: SharedPreferences? = null
|
||||||
|
|
||||||
|
fun init(context: Context) {
|
||||||
|
val masterKey = MasterKey.Builder(context)
|
||||||
|
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||||
|
.build()
|
||||||
|
prefs = EncryptedSharedPreferences.create(
|
||||||
|
context,
|
||||||
|
FILE_NAME,
|
||||||
|
masterKey,
|
||||||
|
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||||
|
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(key: String, value: String) {
|
||||||
|
prefs?.edit()?.putString(key, value)?.apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(key: String): String? = prefs?.getString(key, null)
|
||||||
|
|
||||||
|
fun delete(key: String) {
|
||||||
|
prefs?.edit()?.remove(key)?.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package com.example.medilightv2android.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.example.medilightv2android.models.MeasurementRecord
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
|
||||||
|
class MeasurementHistoryStore private constructor(context: Context) {
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var instance: MeasurementHistoryStore? = null
|
||||||
|
fun getInstance(context: Context): MeasurementHistoryStore =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: MeasurementHistoryStore(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val MAX_RECORDS = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences("measurement_history", Context.MODE_PRIVATE)
|
||||||
|
private val gson = Gson()
|
||||||
|
private val key = "measurement_history"
|
||||||
|
|
||||||
|
var records: List<MeasurementRecord>
|
||||||
|
get() {
|
||||||
|
val json = prefs.getString(key, null) ?: return emptyList()
|
||||||
|
return try {
|
||||||
|
val type = object : TypeToken<List<MeasurementRecord>>() {}.type
|
||||||
|
gson.fromJson(json, type)
|
||||||
|
} catch (_: Exception) { emptyList() }
|
||||||
|
}
|
||||||
|
private set(value) {
|
||||||
|
val trimmed = if (value.size > MAX_RECORDS) value.take(MAX_RECORDS) else value
|
||||||
|
prefs.edit().putString(key, gson.toJson(trimmed)).apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addRecord(record: MeasurementRecord) {
|
||||||
|
val current = records.toMutableList()
|
||||||
|
current.add(0, record)
|
||||||
|
records = current
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearAll() {
|
||||||
|
prefs.edit().remove(key).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.example.medilightv2android.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import com.example.medilightv2android.ble.BleManager
|
||||||
|
import com.example.medilightv2android.models.SensorMode
|
||||||
|
|
||||||
|
class MeasurementService private constructor(context: Context) {
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var instance: MeasurementService? = null
|
||||||
|
fun getInstance(context: Context): MeasurementService =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: MeasurementService(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val bleManager = BleManager.getInstance(context)
|
||||||
|
|
||||||
|
val isMeasuring = mutableStateOf(false)
|
||||||
|
|
||||||
|
fun performMeasurement(mode: SensorMode, completion: (Boolean) -> Unit) {
|
||||||
|
isMeasuring.value = true
|
||||||
|
when (mode) {
|
||||||
|
SensorMode.NIRS -> performNirsMeasurement(completion)
|
||||||
|
SensorMode.PIEZO -> performPiezoMeasurement(completion)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performNirsMeasurement(completion: (Boolean) -> Unit) {
|
||||||
|
bleManager.onNirsMcjReceived = { _ ->
|
||||||
|
isMeasuring.value = false
|
||||||
|
completion(true)
|
||||||
|
}
|
||||||
|
bleManager.sendMcjQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun performPiezoMeasurement(completion: (Boolean) -> Unit) {
|
||||||
|
bleManager.onPiezoDataReceived = { _ ->
|
||||||
|
isMeasuring.value = false
|
||||||
|
completion(true)
|
||||||
|
}
|
||||||
|
bleManager.sendBurst(freqOption = 2, cycles = 5, delayUs = 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
isMeasuring.value = false
|
||||||
|
bleManager.sendPiezoStop()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.example.medilightv2android.services
|
||||||
|
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.example.medilightv2android.R
|
||||||
|
import com.example.medilightv2android.models.UrgencyLevel
|
||||||
|
|
||||||
|
class NotificationService private constructor(private val context: Context) {
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var instance: NotificationService? = null
|
||||||
|
fun getInstance(context: Context): NotificationService =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: NotificationService(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "bladder_urgency"
|
||||||
|
private const val CHANNEL_NAME = "Bladder Monitoring"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var lastNotifiedUrgency: String? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
createNotificationChannel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
CHANNEL_NAME,
|
||||||
|
NotificationManager.IMPORTANCE_HIGH
|
||||||
|
).apply {
|
||||||
|
description = "Bladder level urgency notifications"
|
||||||
|
enableVibration(true)
|
||||||
|
}
|
||||||
|
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
notificationManager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendUrgencyNotification(urgency: UrgencyLevel, volume: Int) {
|
||||||
|
val urgencyKey = urgency.rawStringValue
|
||||||
|
if (urgencyKey == "safe" || urgencyKey == lastNotifiedUrgency) return
|
||||||
|
lastNotifiedUrgency = urgencyKey
|
||||||
|
|
||||||
|
val (title, body) = when (urgency) {
|
||||||
|
UrgencyLevel.WARNING -> "Bladder Level Rising" to "Estimated $volume ml. Prepare for catheterization."
|
||||||
|
UrgencyLevel.URGENT -> "Catheterization Needed" to "Estimated $volume ml. Please catheterize now."
|
||||||
|
UrgencyLevel.SAFE -> return
|
||||||
|
}
|
||||||
|
|
||||||
|
val priority = if (urgency == UrgencyLevel.URGENT)
|
||||||
|
NotificationCompat.PRIORITY_MAX else NotificationCompat.PRIORITY_HIGH
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentTitle(title)
|
||||||
|
.setContentText(body)
|
||||||
|
.setPriority(priority)
|
||||||
|
.setAutoCancel(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
try {
|
||||||
|
NotificationManagerCompat.from(context).notify(urgencyKey.hashCode(), notification)
|
||||||
|
} catch (_: SecurityException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancelAll() {
|
||||||
|
lastNotifiedUrgency = null
|
||||||
|
NotificationManagerCompat.from(context).cancelAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package com.example.medilightv2android.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
import com.example.medilightv2android.models.*
|
||||||
|
import com.google.gson.Gson
|
||||||
|
|
||||||
|
class UserStorage private constructor(context: Context) {
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var instance: UserStorage? = null
|
||||||
|
fun getInstance(context: Context): UserStorage =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: UserStorage(context.applicationContext).also { instance = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val prefs: SharedPreferences = context.getSharedPreferences("medilight_prefs", Context.MODE_PRIVATE)
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
init {
|
||||||
|
migratePinToKeychain()
|
||||||
|
}
|
||||||
|
|
||||||
|
var user: User
|
||||||
|
get() {
|
||||||
|
val json = prefs.getString("user_data", null) ?: return User()
|
||||||
|
return try { gson.fromJson(json, User::class.java) } catch (_: Exception) { User() }
|
||||||
|
}
|
||||||
|
set(value) { prefs.edit().putString("user_data", gson.toJson(value)).apply() }
|
||||||
|
|
||||||
|
var isRegistered: Boolean
|
||||||
|
get() = prefs.getBoolean("is_registered", false)
|
||||||
|
set(value) { prefs.edit().putBoolean("is_registered", value).apply() }
|
||||||
|
|
||||||
|
var pin: String
|
||||||
|
get() {
|
||||||
|
KeychainService.load("user_pin")?.let { return it }
|
||||||
|
return prefs.getString("user_pin", "") ?: ""
|
||||||
|
}
|
||||||
|
set(value) {
|
||||||
|
KeychainService.save("user_pin", value)
|
||||||
|
prefs.edit().remove("user_pin").apply()
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasPin: Boolean
|
||||||
|
get() = prefs.getBoolean("has_pin", false)
|
||||||
|
set(value) { prefs.edit().putBoolean("has_pin", value).apply() }
|
||||||
|
|
||||||
|
var sensorMode: SensorMode
|
||||||
|
get() {
|
||||||
|
val raw = prefs.getString("sensor_mode", null) ?: return SensorMode.PIEZO
|
||||||
|
return try { SensorMode.valueOf(raw) } catch (_: Exception) { SensorMode.PIEZO }
|
||||||
|
}
|
||||||
|
set(value) { prefs.edit().putString("sensor_mode", value.name).apply() }
|
||||||
|
|
||||||
|
var lastDeviceAddress: String?
|
||||||
|
get() = prefs.getString("last_device_address", null)
|
||||||
|
set(value) { prefs.edit().putString("last_device_address", value).apply() }
|
||||||
|
|
||||||
|
var lastDeviceName: String?
|
||||||
|
get() = prefs.getString("last_device_name", null)
|
||||||
|
set(value) { prefs.edit().putString("last_device_name", value).apply() }
|
||||||
|
|
||||||
|
var catheterizationLevel: Int
|
||||||
|
get() {
|
||||||
|
val v = prefs.getInt("catheterization_level", 0)
|
||||||
|
return if (v > 0) v else 8
|
||||||
|
}
|
||||||
|
set(value) { prefs.edit().putInt("catheterization_level", value).apply() }
|
||||||
|
|
||||||
|
var bladderLevel: Int
|
||||||
|
get() = prefs.getInt("bladder_level", 0)
|
||||||
|
set(value) { prefs.edit().putInt("bladder_level", value).apply() }
|
||||||
|
|
||||||
|
var hasCompletedOnboarding: Boolean
|
||||||
|
get() = prefs.getBoolean("has_completed_onboarding", false)
|
||||||
|
set(value) { prefs.edit().putBoolean("has_completed_onboarding", value).apply() }
|
||||||
|
|
||||||
|
var piezoSettings: PiezoSettings
|
||||||
|
get() {
|
||||||
|
val json = prefs.getString("piezo_settings", null) ?: return PiezoSettings()
|
||||||
|
return try { gson.fromJson(json, PiezoSettings::class.java) } catch (_: Exception) { PiezoSettings() }
|
||||||
|
}
|
||||||
|
set(value) { prefs.edit().putString("piezo_settings", gson.toJson(value)).apply() }
|
||||||
|
|
||||||
|
private fun migratePinToKeychain() {
|
||||||
|
val existingPin = prefs.getString("user_pin", null)
|
||||||
|
if (existingPin.isNullOrEmpty()) return
|
||||||
|
val keychainPin = KeychainService.load("user_pin")
|
||||||
|
if (!keychainPin.isNullOrEmpty()) {
|
||||||
|
prefs.edit().remove("user_pin").apply()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
KeychainService.save("user_pin", existingPin)
|
||||||
|
prefs.edit().remove("user_pin").apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
package com.example.medilightv2android.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import app.rive.runtime.kotlin.RiveAnimationView
|
||||||
|
import app.rive.runtime.kotlin.core.Fit
|
||||||
|
import com.example.medilightv2android.R
|
||||||
|
import com.example.medilightv2android.models.UrgencyLevel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.random.Random
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bladdy Rive animation that responds to urgency level changes.
|
||||||
|
* Used in monitoring views where bladder level is tracked.
|
||||||
|
*
|
||||||
|
* State machine inputs:
|
||||||
|
* 0 = idle, 1 = wave, 3 = urgent, 4 = warning, 5 = eyes
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BladdyRiveView(
|
||||||
|
urgency: UrgencyLevel,
|
||||||
|
size: Dp = 100.dp,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val riveView = remember { mutableListOf<RiveAnimationView>() }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.size(size),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
RiveAnimationView(context).apply {
|
||||||
|
setRiveResource(
|
||||||
|
R.raw.bladdy_final12,
|
||||||
|
stateMachineName = "State Machine 1",
|
||||||
|
fit = Fit.CONTAIN
|
||||||
|
)
|
||||||
|
riveView.clear()
|
||||||
|
riveView.add(this)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(size)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respond to urgency changes
|
||||||
|
LaunchedEffect(urgency) {
|
||||||
|
val view = riveView.firstOrNull() ?: return@LaunchedEffect
|
||||||
|
when (urgency) {
|
||||||
|
UrgencyLevel.SAFE -> {
|
||||||
|
view.setNumberState("State Machine 1", "state", 0f)
|
||||||
|
// Random idle animations every 4 seconds
|
||||||
|
while (true) {
|
||||||
|
delay(4000)
|
||||||
|
val randomState = listOf(0f, 1f, 5f).random()
|
||||||
|
view.setNumberState("State Machine 1", "state", randomState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UrgencyLevel.WARNING -> {
|
||||||
|
view.setNumberState("State Machine 1", "state", 4f)
|
||||||
|
}
|
||||||
|
UrgencyLevel.URGENT -> {
|
||||||
|
view.setNumberState("State Machine 1", "state", 3f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bladdy Rive animation for idle/decorative use.
|
||||||
|
* Plays random animations (idle, wave, eyes) every 5 seconds.
|
||||||
|
* Used on home, onboarding, registration, sensor select, pin, scan screens.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun BladdyIdleRiveView(
|
||||||
|
size: Dp = 220.dp,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val riveView = remember { mutableListOf<RiveAnimationView>() }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier.size(size),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
AndroidView(
|
||||||
|
factory = { context ->
|
||||||
|
RiveAnimationView(context).apply {
|
||||||
|
setRiveResource(
|
||||||
|
R.raw.bladdy_final12,
|
||||||
|
stateMachineName = "State Machine 1",
|
||||||
|
fit = Fit.CONTAIN
|
||||||
|
)
|
||||||
|
setNumberState("State Machine 1", "state", 0f)
|
||||||
|
riveView.clear()
|
||||||
|
riveView.add(this)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(size)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random idle animations every 5 seconds
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
while (true) {
|
||||||
|
delay(5000)
|
||||||
|
val view = riveView.firstOrNull() ?: continue
|
||||||
|
val randomState = listOf(0f, 1f, 5f).random()
|
||||||
|
view.setNumberState("State Machine 1", "state", randomState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.example.medilightv2android.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.ui.theme.MlPrimary
|
||||||
|
|
||||||
|
enum class BleReconnectionState {
|
||||||
|
DISCONNECTED, RECONNECTING, FAILED
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun BleDisconnectionBanner(
|
||||||
|
state: BleReconnectionState,
|
||||||
|
onReconnect: () -> Unit
|
||||||
|
) {
|
||||||
|
val bannerColor = when (state) {
|
||||||
|
BleReconnectionState.DISCONNECTED -> Color(0xFFFF9500)
|
||||||
|
BleReconnectionState.RECONNECTING -> MlPrimary
|
||||||
|
BleReconnectionState.FAILED -> Color.Red
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.shadow(8.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.1f))
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(bannerColor)
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
when (state) {
|
||||||
|
BleReconnectionState.RECONNECTING -> {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
color = Color.White,
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Text("!", color = Color.White, fontWeight = FontWeight.Bold, fontSize = 20.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = if (state == BleReconnectionState.RECONNECTING) "Reconnecting..." else "Device Disconnected",
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (state == BleReconnectionState.FAILED) "Could not reconnect" else "Connection lost",
|
||||||
|
color = Color.White.copy(alpha = 0.8f),
|
||||||
|
fontSize = 12.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state != BleReconnectionState.RECONNECTING) {
|
||||||
|
Button(
|
||||||
|
onClick = onReconnect,
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.White),
|
||||||
|
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp)
|
||||||
|
) {
|
||||||
|
Text("Reconnect", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = bannerColor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.example.medilightv2android.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
|
import androidx.compose.animation.core.tween
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.example.medilightv2android.models.UrgencyLevel
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DonutChartView(
|
||||||
|
progress: Double,
|
||||||
|
urgency: UrgencyLevel,
|
||||||
|
lineWidth: Dp = 24.dp,
|
||||||
|
size: Dp = 240.dp
|
||||||
|
) {
|
||||||
|
val animatedProgress by animateFloatAsState(
|
||||||
|
targetValue = progress.toFloat().coerceIn(0f, 1f),
|
||||||
|
animationSpec = tween(600),
|
||||||
|
label = "progress"
|
||||||
|
)
|
||||||
|
|
||||||
|
val gradientColors = urgency.gradientColors
|
||||||
|
|
||||||
|
Canvas(modifier = Modifier.size(size)) {
|
||||||
|
val strokeWidth = lineWidth.toPx()
|
||||||
|
|
||||||
|
// Background track
|
||||||
|
drawCircle(
|
||||||
|
color = Color.Gray.copy(alpha = 0.12f),
|
||||||
|
style = Stroke(width = strokeWidth)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Progress arc
|
||||||
|
if (animatedProgress > 0f) {
|
||||||
|
drawArc(
|
||||||
|
color = gradientColors.first(),
|
||||||
|
startAngle = -90f,
|
||||||
|
sweepAngle = 360f * animatedProgress,
|
||||||
|
useCenter = false,
|
||||||
|
style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.example.medilightv2android.ui.theme
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
// Material theme colors
|
||||||
|
val Purple80 = Color(0xFFD0BCFF)
|
||||||
|
val PurpleGrey80 = Color(0xFFCCC2DC)
|
||||||
|
val Pink80 = Color(0xFFEFB8C8)
|
||||||
|
val Purple40 = Color(0xFF6650a4)
|
||||||
|
val PurpleGrey40 = Color(0xFF625b71)
|
||||||
|
val Pink40 = Color(0xFF7D5260)
|
||||||
|
|
||||||
|
// MediLight theme colors
|
||||||
|
val MlPrimary = Color(0xFF008FFF)
|
||||||
|
val MlTeal = Color(0xFF00DB9F)
|
||||||
|
val MlBackground = Color(0xFFF5F8FF)
|
||||||
|
val MlCardBackground = Color.White
|
||||||
|
val MlSecondaryText = Color(0xFF80808C)
|
||||||
|
val MlInputBackground = Color(0xFFF5F8FF)
|
||||||
|
val MlInputBorder = Color(0xFF00DB9F)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.example.medilightv2android.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 Medilightv2androidTheme(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.example.medilightv2android.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,207 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.calibration
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.ble.BleManager
|
||||||
|
import com.example.medilightv2android.managers.NirsManager
|
||||||
|
import com.example.medilightv2android.models.NirsMagResponse
|
||||||
|
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun CalibrationView(appState: AppState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val bleManager = remember { BleManager.getInstance(context) }
|
||||||
|
val nirsManager = remember { NirsManager.instance }
|
||||||
|
val handler = remember { Handler(Looper.getMainLooper()) }
|
||||||
|
|
||||||
|
var statusMessage by remember { mutableStateOf("Initializing...") }
|
||||||
|
var isComplete by remember { mutableStateOf(false) }
|
||||||
|
var isFailed by remember { mutableStateOf(false) }
|
||||||
|
var didReceiveResponse by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val isServiceReady by bleManager.isServiceReady
|
||||||
|
|
||||||
|
fun completeCalibration() {
|
||||||
|
if (isComplete) return
|
||||||
|
statusMessage = "NIRS Calibrated"
|
||||||
|
isComplete = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun failCalibration() {
|
||||||
|
if (isComplete || isFailed) return
|
||||||
|
statusMessage = "No response from device. Check the device is powered on and nearby."
|
||||||
|
isFailed = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startCalibration() {
|
||||||
|
statusMessage = "Powering on NIRS transducer..."
|
||||||
|
|
||||||
|
bleManager.onNirsPowerOnReceived = {
|
||||||
|
didReceiveResponse = true
|
||||||
|
statusMessage = "Activating sensor..."
|
||||||
|
handler.postDelayed({ bleManager.sendMqqQuery() }, 2000)
|
||||||
|
}
|
||||||
|
|
||||||
|
bleManager.onNirsSensorActivated = {
|
||||||
|
statusMessage = "Reading gain calibration..."
|
||||||
|
handler.postDelayed({ bleManager.sendMagQuery() }, 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
bleManager.onNirsMagReceived = { data ->
|
||||||
|
NirsMagResponse.parse(data)?.let { response ->
|
||||||
|
nirsManager.setCalibrationGain(response)
|
||||||
|
}
|
||||||
|
completeCalibration()
|
||||||
|
}
|
||||||
|
|
||||||
|
bleManager.sendNirsPowerOn()
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
handler.postDelayed({
|
||||||
|
if (didReceiveResponse) completeCalibration() else failCalibration()
|
||||||
|
}, 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun retryCalibration() {
|
||||||
|
isFailed = false
|
||||||
|
isComplete = false
|
||||||
|
didReceiveResponse = false
|
||||||
|
statusMessage = "Initializing..."
|
||||||
|
startCalibration()
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isServiceReady) {
|
||||||
|
if (isServiceReady) startCalibration()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Bladdy with checkmark
|
||||||
|
Box {
|
||||||
|
BladdyIdleRiveView(size = 160.dp)
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.CheckCircle,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MlTeal,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.align(Alignment.BottomEnd)
|
||||||
|
.offset(x = (-10).dp, y = (-10).dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = when {
|
||||||
|
isFailed -> "Calibration Failed"
|
||||||
|
isComplete -> "Calibration Complete"
|
||||||
|
else -> "Calibrating NIRS..."
|
||||||
|
},
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = if (isFailed) Color.Red else Color.Unspecified
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = statusMessage,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MlSecondaryText,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
if (isFailed) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { retryCalibration() },
|
||||||
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
contentPadding = PaddingValues()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().background(
|
||||||
|
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||||
|
RoundedCornerShape(16.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Refresh, null, tint = Color.White)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text("Retry", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
TextButton(
|
||||||
|
onClick = { appState.calibrationComplete() },
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
) {
|
||||||
|
Text("Skip", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (isComplete) {
|
||||||
|
Button(
|
||||||
|
onClick = { appState.calibrationComplete() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
contentPadding = PaddingValues()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().background(
|
||||||
|
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||||
|
RoundedCornerShape(16.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("Continue", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.connection
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material.icons.filled.Warning
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.ble.BleDevice
|
||||||
|
import com.example.medilightv2android.ble.BleManager
|
||||||
|
import com.example.medilightv2android.models.SensorMode
|
||||||
|
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DeviceScanView(appState: AppState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val bleManager = remember { BleManager.getInstance(context) }
|
||||||
|
var connectingDeviceAddress by remember { mutableStateOf<String?>(null) }
|
||||||
|
var permissionsGranted by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val devicePrefix = if (appState.sensorMode == SensorMode.NIRS) "2025A04DEV" else "2025MEDIP"
|
||||||
|
|
||||||
|
val filteredDevices = bleManager.discoveredDevices
|
||||||
|
.filter { it.name.startsWith(devicePrefix) }
|
||||||
|
.sortedByDescending { it.rssi }
|
||||||
|
|
||||||
|
val isConnected by bleManager.isConnected
|
||||||
|
val isScanning by bleManager.isScanning
|
||||||
|
val bluetoothEnabled by bleManager.bluetoothEnabled
|
||||||
|
val connectionError by bleManager.connectionError
|
||||||
|
|
||||||
|
// BLE permissions
|
||||||
|
val requiredPermissions = remember {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.BLUETOOTH_SCAN,
|
||||||
|
Manifest.permission.BLUETOOTH_CONNECT,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
arrayOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun checkPermissions(): Boolean {
|
||||||
|
return requiredPermissions.all {
|
||||||
|
ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val permissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions()
|
||||||
|
) { results ->
|
||||||
|
permissionsGranted = results.values.all { it }
|
||||||
|
if (permissionsGranted) {
|
||||||
|
bleManager.startScan()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (checkPermissions()) {
|
||||||
|
permissionsGranted = true
|
||||||
|
bleManager.startScan()
|
||||||
|
} else {
|
||||||
|
permissionLauncher.launch(requiredPermissions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(isConnected) {
|
||||||
|
if (isConnected) {
|
||||||
|
connectingDeviceAddress = null
|
||||||
|
appState.deviceConnected()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(connectionError) {
|
||||||
|
if (connectionError != null) connectingDeviceAddress = null
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) { onDispose { bleManager.stopScan() } }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MlBackground)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { appState.backToSensorSelect() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text("Connect Device", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Searching for $devicePrefix devices...", fontSize = 14.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Bluetooth off warning
|
||||||
|
if (!bluetoothEnabled) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(Color(0xFFFF9500))
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Warning, "Warning", tint = Color.White)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text("Bluetooth is off", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
|
||||||
|
Text("Enable Bluetooth in Settings", color = Color.White.copy(alpha = 0.8f), fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Permission denied warning
|
||||||
|
if (!permissionsGranted) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(Color(0xFFFF9500))
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Warning, "Warning", tint = Color.White)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column {
|
||||||
|
Text("Permissions required", fontWeight = FontWeight.Bold, color = Color.White, fontSize = 14.sp)
|
||||||
|
Text("Bluetooth and Location permissions are needed to scan for devices", color = Color.White.copy(alpha = 0.8f), fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connection error
|
||||||
|
connectionError?.let { error ->
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(Color.Red)
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Close, "Error", tint = Color.White)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text(error, color = Color.White, fontSize = 13.sp, modifier = Modifier.weight(1f))
|
||||||
|
IconButton(onClick = { bleManager.connectionError.value = null }) {
|
||||||
|
Icon(Icons.Default.Close, "Dismiss", tint = Color.White.copy(alpha = 0.8f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device list
|
||||||
|
if (filteredDevices.isEmpty() && isScanning) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
BladdyIdleRiveView(size = 140.dp)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Scanning...", color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
} else if (filteredDevices.isEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.padding(horizontal = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text("No devices found", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
TroubleshootItem("1.", "Make sure the device is powered on")
|
||||||
|
TroubleshootItem("2.", "Keep the device within 1 meter")
|
||||||
|
TroubleshootItem("3.", "Check Bluetooth is enabled in Settings")
|
||||||
|
TroubleshootItem("4.", "Try restarting the device")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
contentPadding = PaddingValues(horizontal = 24.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
items(filteredDevices, key = { it.address }) { device ->
|
||||||
|
DeviceRow(
|
||||||
|
device = device,
|
||||||
|
isConnecting = connectingDeviceAddress == device.address,
|
||||||
|
enabled = connectingDeviceAddress == null,
|
||||||
|
onClick = {
|
||||||
|
connectingDeviceAddress = device.address
|
||||||
|
bleManager.connect(device)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scan button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (!permissionsGranted) {
|
||||||
|
permissionLauncher.launch(requiredPermissions)
|
||||||
|
} else if (isScanning) {
|
||||||
|
bleManager.stopScan()
|
||||||
|
} else {
|
||||||
|
bleManager.startScan()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
.height(50.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
contentPadding = PaddingValues()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||||
|
shape = RoundedCornerShape(14.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
!permissionsGranted -> "Grant Permissions"
|
||||||
|
isScanning -> "Stop Scan"
|
||||||
|
else -> "Start Scan"
|
||||||
|
},
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DeviceRow(device: BleDevice, isConnecting: Boolean, enabled: Boolean, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||||
|
.clickable(enabled = enabled, onClick = onClick),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MlPrimary.copy(alpha = 0.1f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("BLE", color = MlPrimary, fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(device.name, fontSize = 16.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
SignalBars(rssi = device.rssi)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(signalLabel(device.rssi), fontSize = 12.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnecting) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(24.dp), strokeWidth = 2.dp)
|
||||||
|
} else {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, null, tint = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SignalBars(rssi: Int) {
|
||||||
|
val strength = when {
|
||||||
|
rssi >= -50 -> 4
|
||||||
|
rssi >= -65 -> 3
|
||||||
|
rssi >= -80 -> 2
|
||||||
|
else -> 1
|
||||||
|
}
|
||||||
|
val color = when {
|
||||||
|
strength >= 3 -> Color.Green
|
||||||
|
strength == 2 -> Color(0xFFFF9500)
|
||||||
|
else -> Color.Red
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), verticalAlignment = Alignment.Bottom) {
|
||||||
|
repeat(4) { i ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(4.dp)
|
||||||
|
.height((6 + i * 3).dp)
|
||||||
|
.clip(RoundedCornerShape(1.dp))
|
||||||
|
.background(if (i < strength) color else Color.Gray.copy(alpha = 0.3f))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun signalLabel(rssi: Int): String = when {
|
||||||
|
rssi >= -50 -> "Excellent"
|
||||||
|
rssi >= -65 -> "Good"
|
||||||
|
rssi >= -80 -> "Fair"
|
||||||
|
else -> "Weak"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun TroubleshootItem(number: String, text: String) {
|
||||||
|
Row(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
|
Text(number, fontSize = 13.sp, color = MlPrimary, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(text, fontSize = 13.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.detectTapGestures
|
||||||
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.nativeCanvas
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.models.*
|
||||||
|
import com.example.medilightv2android.services.MeasurementHistoryStore
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun MeasurementHistoryView(appState: AppState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val records = remember { MeasurementHistoryStore.getInstance(context).records }
|
||||||
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MlBackground)
|
||||||
|
) {
|
||||||
|
// Top bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { appState.backToMonitoring() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
|
||||||
|
}
|
||||||
|
Text("History", fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("${records.size} records", fontSize = 13.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab picker
|
||||||
|
TabRow(selectedTabIndex = selectedTab, modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||||
|
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { Text("History", modifier = Modifier.padding(12.dp)) }
|
||||||
|
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { Text("Trends", modifier = Modifier.padding(12.dp)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (records.isEmpty()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text("No measurements yet", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Tap Spot to start measuring", fontSize = 14.sp, color = MlSecondaryText.copy(alpha = 0.7f))
|
||||||
|
}
|
||||||
|
} else if (selectedTab == 0) {
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
|
) {
|
||||||
|
items(records) { record ->
|
||||||
|
RecordCard(record)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Trends tab
|
||||||
|
val measurements = records.filter { it.eventType == MeasurementEventType.MEASUREMENT }.reversed()
|
||||||
|
val cathEvents = records.filter { it.eventType == MeasurementEventType.CATHETERIZATION }
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
contentPadding = PaddingValues(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
// Level chart
|
||||||
|
item {
|
||||||
|
InteractiveChartCard(
|
||||||
|
title = "Bladder Level",
|
||||||
|
measurements = measurements,
|
||||||
|
valueExtractor = { it.level.toFloat() },
|
||||||
|
maxValue = BladderLevel.MAX_LEVEL.toFloat(),
|
||||||
|
lineColor = MlPrimary.copy(alpha = 0.8f),
|
||||||
|
pointColorExtractor = { it.urgencyLevel.accentColor },
|
||||||
|
thresholdValue = appState.bladderLevel.catheterizationThreshold.toFloat(),
|
||||||
|
unit = "Lv"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Volume chart
|
||||||
|
if (measurements.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
InteractiveChartCard(
|
||||||
|
title = "Estimated Volume",
|
||||||
|
measurements = measurements,
|
||||||
|
valueExtractor = { it.estimatedVolume.toFloat() },
|
||||||
|
maxValue = measurements.maxOf { it.estimatedVolume }.toFloat().coerceAtLeast(1f),
|
||||||
|
lineColor = MlTeal,
|
||||||
|
pointColorExtractor = { MlTeal },
|
||||||
|
unit = "ml"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catheterization log
|
||||||
|
if (cathEvents.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||||
|
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp))
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
|
Text("Catheterization Log", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
cathEvents.forEach { event ->
|
||||||
|
Row(modifier = Modifier.padding(vertical = 4.dp)) {
|
||||||
|
Text(formatDate(event.timestamp), fontSize = 13.sp)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("at Lv.${event.level}", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InteractiveChartCard(
|
||||||
|
title: String,
|
||||||
|
measurements: List<MeasurementRecord>,
|
||||||
|
valueExtractor: (MeasurementRecord) -> Float,
|
||||||
|
maxValue: Float,
|
||||||
|
lineColor: Color,
|
||||||
|
pointColorExtractor: (MeasurementRecord) -> Color,
|
||||||
|
thresholdValue: Float? = null,
|
||||||
|
unit: String
|
||||||
|
) {
|
||||||
|
var scale by remember { mutableFloatStateOf(1f) }
|
||||||
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
||||||
|
var selectedIndex by remember { mutableIntStateOf(-1) }
|
||||||
|
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||||
|
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp))
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(title, fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
if (scale > 1.05f) {
|
||||||
|
Text(
|
||||||
|
String.format("%.0f%%", scale * 100),
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = MlPrimary,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Tooltip
|
||||||
|
if (selectedIndex in measurements.indices) {
|
||||||
|
val record = measurements[selectedIndex]
|
||||||
|
val value = valueExtractor(record)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MlPrimary.copy(alpha = 0.08f))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
formatDateTime(record.timestamp),
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
color = MlPrimary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
"${if (unit == "Lv") value.toInt().toString() else String.format("%.0f", value)} $unit",
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MlPrimary
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
record.urgencyLevel.rawStringValue,
|
||||||
|
fontSize = 11.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = record.urgencyLevel.accentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (measurements.isEmpty()) {
|
||||||
|
Text("No measurement data", fontSize = 12.sp, color = MlSecondaryText)
|
||||||
|
} else {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val textPaint = remember {
|
||||||
|
android.graphics.Paint().apply {
|
||||||
|
color = android.graphics.Color.parseColor("#80808C")
|
||||||
|
textSize = with(density) { 9.sp.toPx() }
|
||||||
|
isAntiAlias = true
|
||||||
|
textAlign = android.graphics.Paint.Align.CENTER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(200.dp)
|
||||||
|
.clipToBounds()
|
||||||
|
.pointerInput(measurements.size) {
|
||||||
|
detectTransformGestures { _, pan, zoom, _ ->
|
||||||
|
val newScale = (scale * zoom).coerceIn(1f, 5f)
|
||||||
|
scale = newScale
|
||||||
|
val totalWidth = size.width * scale
|
||||||
|
val maxOff = (totalWidth - size.width).coerceAtLeast(0f)
|
||||||
|
offsetX = (offsetX + pan.x).coerceIn(-maxOff, 0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.pointerInput(measurements.size, scale, offsetX) {
|
||||||
|
detectTapGestures { tapOffset ->
|
||||||
|
if (measurements.size < 2) {
|
||||||
|
selectedIndex = if (selectedIndex == 0) -1 else 0
|
||||||
|
return@detectTapGestures
|
||||||
|
}
|
||||||
|
val totalWidth = size.width * scale
|
||||||
|
val stepX = totalWidth / (measurements.size - 1).toFloat()
|
||||||
|
val dataX = tapOffset.x - offsetX
|
||||||
|
val idx = (dataX / stepX + 0.5f).toInt().coerceIn(0, measurements.size - 1)
|
||||||
|
selectedIndex = if (selectedIndex == idx) -1 else idx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (measurements.size < 2) {
|
||||||
|
val value = valueExtractor(measurements[0])
|
||||||
|
val y = size.height * 0.5f
|
||||||
|
drawCircle(pointColorExtractor(measurements[0]), radius = 8f, center = Offset(size.width / 2, y))
|
||||||
|
return@Canvas
|
||||||
|
}
|
||||||
|
|
||||||
|
val totalWidth = size.width * scale
|
||||||
|
val stepX = totalWidth / (measurements.size - 1).toFloat()
|
||||||
|
val chartHeight = size.height - 24f
|
||||||
|
val topPadding = 10f
|
||||||
|
|
||||||
|
// Threshold line (dashed)
|
||||||
|
thresholdValue?.let { threshold ->
|
||||||
|
val thresholdY = topPadding + chartHeight - (threshold / maxValue * chartHeight)
|
||||||
|
drawLine(
|
||||||
|
Color.Red.copy(alpha = 0.4f),
|
||||||
|
Offset(0f, thresholdY),
|
||||||
|
Offset(size.width, thresholdY),
|
||||||
|
strokeWidth = 2f,
|
||||||
|
pathEffect = PathEffect.dashPathEffect(floatArrayOf(8f, 6f))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw line path
|
||||||
|
val path = Path()
|
||||||
|
measurements.forEachIndexed { i, r ->
|
||||||
|
val x = i * stepX + offsetX
|
||||||
|
val value = valueExtractor(r)
|
||||||
|
val y = topPadding + chartHeight - (value / maxValue * chartHeight)
|
||||||
|
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||||
|
}
|
||||||
|
drawPath(path, color = lineColor, style = Stroke(width = 3f))
|
||||||
|
|
||||||
|
// Draw points and labels
|
||||||
|
val labelInterval = when {
|
||||||
|
measurements.size <= 6 -> 1
|
||||||
|
measurements.size <= 15 -> 2
|
||||||
|
measurements.size <= 30 -> 5
|
||||||
|
else -> 10
|
||||||
|
}
|
||||||
|
|
||||||
|
measurements.forEachIndexed { i, r ->
|
||||||
|
val x = i * stepX + offsetX
|
||||||
|
if (x < -20f || x > size.width + 20f) return@forEachIndexed
|
||||||
|
val value = valueExtractor(r)
|
||||||
|
val y = topPadding + chartHeight - (value / maxValue * chartHeight)
|
||||||
|
val isSelected = i == selectedIndex
|
||||||
|
val pointColor = pointColorExtractor(r)
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
// Vertical guide
|
||||||
|
drawLine(
|
||||||
|
pointColor.copy(alpha = 0.3f),
|
||||||
|
Offset(x, topPadding),
|
||||||
|
Offset(x, topPadding + chartHeight),
|
||||||
|
strokeWidth = 1f,
|
||||||
|
pathEffect = PathEffect.dashPathEffect(floatArrayOf(4f, 4f))
|
||||||
|
)
|
||||||
|
// Selection ring
|
||||||
|
drawCircle(pointColor.copy(alpha = 0.2f), radius = 16f, center = Offset(x, y))
|
||||||
|
drawCircle(pointColor, radius = 8f, center = Offset(x, y))
|
||||||
|
drawCircle(Color.White, radius = 4f, center = Offset(x, y))
|
||||||
|
} else {
|
||||||
|
drawCircle(pointColor, radius = 5f, center = Offset(x, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time labels
|
||||||
|
if (i % labelInterval == 0 || i == measurements.size - 1) {
|
||||||
|
val label = formatShortTime(r.timestamp)
|
||||||
|
drawContext.canvas.nativeCanvas.drawText(
|
||||||
|
label, x, size.height, textPaint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hint text
|
||||||
|
if (measurements.size > 3 && scale < 1.05f && selectedIndex < 0) {
|
||||||
|
Text(
|
||||||
|
"Pinch to zoom \u2022 Tap point for details",
|
||||||
|
fontSize = 10.sp,
|
||||||
|
color = MlSecondaryText.copy(alpha = 0.5f),
|
||||||
|
modifier = Modifier.padding(top = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RecordCard(record: MeasurementRecord) {
|
||||||
|
val isCath = record.eventType == MeasurementEventType.CATHETERIZATION
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||||
|
modifier = Modifier.fillMaxWidth().shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(14.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(40.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (isCath) MlPrimary.copy(alpha = 0.12f)
|
||||||
|
else record.urgencyLevel.accentColor.copy(alpha = 0.12f)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
if (isCath) "C" else "M",
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = if (isCath) MlPrimary else record.urgencyLevel.accentColor
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(14.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
if (isCath) "Catheterization" else "Measurement",
|
||||||
|
fontSize = 14.sp, fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Text(formatDate(record.timestamp), fontSize = 12.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Lv.${record.level}", fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(record.urgencyLevel.accentColor))
|
||||||
|
}
|
||||||
|
Text("${record.estimatedVolume} ml", fontSize = 12.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDate(timestamp: Long): String {
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
val now = Calendar.getInstance()
|
||||||
|
cal.timeInMillis = timestamp
|
||||||
|
|
||||||
|
return when {
|
||||||
|
cal.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR) &&
|
||||||
|
cal.get(Calendar.YEAR) == now.get(Calendar.YEAR) -> {
|
||||||
|
SimpleDateFormat("h:mm a", Locale.US).format(Date(timestamp))
|
||||||
|
}
|
||||||
|
now.timeInMillis - timestamp < 2 * 24 * 60 * 60 * 1000 -> {
|
||||||
|
"Yesterday " + SimpleDateFormat("h:mm a", Locale.US).format(Date(timestamp))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
SimpleDateFormat("MMM d, h:mm a", Locale.US).format(Date(timestamp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatDateTime(timestamp: Long): String {
|
||||||
|
val cal = Calendar.getInstance()
|
||||||
|
val now = Calendar.getInstance()
|
||||||
|
cal.timeInMillis = timestamp
|
||||||
|
|
||||||
|
return when {
|
||||||
|
cal.get(Calendar.DAY_OF_YEAR) == now.get(Calendar.DAY_OF_YEAR) &&
|
||||||
|
cal.get(Calendar.YEAR) == now.get(Calendar.YEAR) -> {
|
||||||
|
"Today " + SimpleDateFormat("h:mm:ss a", Locale.US).format(Date(timestamp))
|
||||||
|
}
|
||||||
|
now.timeInMillis - timestamp < 2 * 24 * 60 * 60 * 1000 -> {
|
||||||
|
"Yesterday " + SimpleDateFormat("h:mm:ss a", Locale.US).format(Date(timestamp))
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
SimpleDateFormat("MMM d, h:mm:ss a", Locale.US).format(Date(timestamp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatShortTime(timestamp: Long): String {
|
||||||
|
return SimpleDateFormat("HH:mm", Locale.US).format(Date(timestamp))
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.home
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun HomeView(appState: AppState) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MlBackground),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
BladdyIdleRiveView(size = 220.dp)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text("MediLight", fontSize = 32.sp, fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
Text("Smart Bladder Monitoring", fontSize = 16.sp, color = MlSecondaryText)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { appState.startFromHome() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp)
|
||||||
|
.padding(bottom = 48.dp)
|
||||||
|
.height(60.dp),
|
||||||
|
shape = RoundedCornerShape(18.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
contentPadding = PaddingValues()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||||
|
shape = RoundedCornerShape(18.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("Start", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,322 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.monitoring
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.ble.BleManager
|
||||||
|
import com.example.medilightv2android.models.BladderLevel
|
||||||
|
import com.example.medilightv2android.models.UrgencyLevel
|
||||||
|
import com.example.medilightv2android.ui.components.BladdyRiveView
|
||||||
|
import com.example.medilightv2android.ui.components.BleDisconnectionBanner
|
||||||
|
import com.example.medilightv2android.ui.components.BleReconnectionState
|
||||||
|
import com.example.medilightv2android.ui.components.DonutChartView
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PiezoMonitoringView(appState: AppState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val bleManager = remember { BleManager.getInstance(context) }
|
||||||
|
val handler = remember { Handler(Looper.getMainLooper()) }
|
||||||
|
|
||||||
|
var showSettings by remember { mutableStateOf(false) }
|
||||||
|
var isMeasuring by remember { mutableStateOf(false) }
|
||||||
|
var showCatheterizeConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showDisconnectionBanner by remember { mutableStateOf(false) }
|
||||||
|
var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) }
|
||||||
|
var isAutoMeasuring by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
val urgency = appState.bladderLevel.urgency
|
||||||
|
val batteryLvl by bleManager.batteryLevel
|
||||||
|
val isConnected by bleManager.isConnected
|
||||||
|
|
||||||
|
fun measure() {
|
||||||
|
isMeasuring = true
|
||||||
|
handler.postDelayed({
|
||||||
|
appState.incrementLevel()
|
||||||
|
isMeasuring = false
|
||||||
|
}, 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopAutoMeasure() {
|
||||||
|
isAutoMeasuring = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup disconnect handling
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
bleManager.onUnexpectedDisconnect = {
|
||||||
|
reconnectionState = BleReconnectionState.DISCONNECTED
|
||||||
|
showDisconnectionBanner = true
|
||||||
|
}
|
||||||
|
bleManager.onReconnectionFailed = {
|
||||||
|
reconnectionState = BleReconnectionState.FAILED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catheterize confirmation dialog
|
||||||
|
if (showCatheterizeConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showCatheterizeConfirm = false },
|
||||||
|
title = { Text("Reset bladder level?") },
|
||||||
|
text = { Text("This will reset the bladder level to 0. This action cannot be undone.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
appState.resetLevel()
|
||||||
|
showCatheterizeConfirm = false
|
||||||
|
}) { Text("Catheterize", color = Color.Red) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showCatheterizeConfirm = false }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(urgency.backgroundColor)
|
||||||
|
) {
|
||||||
|
// Top bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { appState.goHome() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
|
||||||
|
}
|
||||||
|
Text("Bladder Monitor", fontSize = 20.sp, fontWeight = FontWeight.Bold, modifier = Modifier.padding(start = 8.dp))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
if (isAutoMeasuring) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(Color.Red.copy(alpha = 0.12f))
|
||||||
|
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(Color.Red))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Auto", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = Color.Red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconButton(onClick = { appState.showHistory() }) {
|
||||||
|
Icon(Icons.Default.History, "History", tint = MlSecondaryText)
|
||||||
|
}
|
||||||
|
IconButton(onClick = { showSettings = !showSettings }) {
|
||||||
|
Icon(Icons.Default.Settings, "Settings", tint = if (showSettings) MlPrimary else MlSecondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Battery
|
||||||
|
Text("$batteryLvl%", fontSize = 13.sp, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(10.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (isConnected) Color.Green else Color.Red)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDisconnectionBanner) {
|
||||||
|
BleDisconnectionBanner(
|
||||||
|
state = reconnectionState,
|
||||||
|
onReconnect = {
|
||||||
|
reconnectionState = BleReconnectionState.RECONNECTING
|
||||||
|
bleManager.reconnectToLastDevice(
|
||||||
|
com.example.medilightv2android.services.UserStorage.getInstance(context).lastDeviceAddress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.padding(top = 16.dp, bottom = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
|
) {
|
||||||
|
// Settings panel
|
||||||
|
if (showSettings) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||||
|
modifier = Modifier.shadow(8.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.06f))
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(20.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Catheterization Threshold", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("Level ${appState.bladderLevel.catheterizationThreshold}", fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||||
|
}
|
||||||
|
Slider(
|
||||||
|
value = appState.bladderLevel.catheterizationThreshold.toFloat(),
|
||||||
|
onValueChange = { appState.updateCatheterizationThreshold(it.toInt()) },
|
||||||
|
valueRange = 1f..10f,
|
||||||
|
steps = 8,
|
||||||
|
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row {
|
||||||
|
Text("Max Volume", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("${appState.piezoSettings.maxVolume} ml", fontSize = 14.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status badge
|
||||||
|
Text(
|
||||||
|
text = urgency.message,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = urgency.accentColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(20.dp))
|
||||||
|
.background(urgency.accentColor.copy(alpha = 0.15f))
|
||||||
|
.padding(horizontal = 20.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Donut chart with Bladdy
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
DonutChartView(
|
||||||
|
progress = appState.bladderLevel.progress,
|
||||||
|
urgency = urgency,
|
||||||
|
lineWidth = 28.dp,
|
||||||
|
size = 240.dp
|
||||||
|
)
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
BladdyRiveView(urgency = urgency, size = 100.dp)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
"${appState.estimatedVolume} ml",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = urgency.accentColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(12.dp))
|
||||||
|
.background(MlCardBackground)
|
||||||
|
.shadow(4.dp, RoundedCornerShape(12.dp), spotColor = Color.Black.copy(alpha = 0.08f))
|
||||||
|
.padding(horizontal = 14.dp, vertical = 6.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Info cards
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
InfoCard("Volume", "${appState.estimatedVolume}", "ml", urgency.accentColor, Modifier.weight(1f))
|
||||||
|
InfoCard("Fill", String.format("%.0f", appState.fillPercentage), "%", urgency.accentColor, Modifier.weight(1f))
|
||||||
|
InfoCard("Level", "${appState.bladderLevel.currentLevel}/${BladderLevel.MAX_LEVEL}", "", urgency.accentColor, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control buttons
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
// Catheterize button
|
||||||
|
Button(
|
||||||
|
onClick = { showCatheterizeConfirm = true },
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.1f))
|
||||||
|
) {
|
||||||
|
Text("Catheterize", fontSize = 16.sp, fontWeight = FontWeight.SemiBold, color = MlPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spot button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (isAutoMeasuring) stopAutoMeasure()
|
||||||
|
else if (!isMeasuring) measure()
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f).height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
contentPadding = PaddingValues(),
|
||||||
|
enabled = !isMeasuring || isAutoMeasuring
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().background(
|
||||||
|
Brush.horizontalGradient(
|
||||||
|
if (isAutoMeasuring) listOf(Color.Red, Color(0xFFFF9500))
|
||||||
|
else urgency.gradientColors
|
||||||
|
),
|
||||||
|
RoundedCornerShape(16.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
if (isMeasuring && !isAutoMeasuring) {
|
||||||
|
CircularProgressIndicator(color = Color.White, modifier = Modifier.size(20.dp), strokeWidth = 2.dp)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
when {
|
||||||
|
isAutoMeasuring -> "Stop"
|
||||||
|
isMeasuring -> "Measuring..."
|
||||||
|
else -> "Spot"
|
||||||
|
},
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
if (isAutoMeasuring) "Measuring every 30s" else "Long press Spot for auto-measurement",
|
||||||
|
fontSize = 11.sp,
|
||||||
|
color = if (isAutoMeasuring) Color.Red.copy(alpha = 0.7f) else MlSecondaryText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun InfoCard(title: String, value: String, unit: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
|
Card(
|
||||||
|
modifier = modifier.shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.04f)),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(vertical = 14.dp).fillMaxWidth(),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
|
Text(value, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||||
|
if (unit.isNotEmpty()) Text(unit, fontSize = 11.sp, color = MlSecondaryText)
|
||||||
|
Text(title, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,347 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.monitoring
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.ble.BleManager
|
||||||
|
import com.example.medilightv2android.managers.NirsManager
|
||||||
|
import com.example.medilightv2android.models.NirsMcjPacket
|
||||||
|
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
||||||
|
import com.example.medilightv2android.ui.components.BleDisconnectionBanner
|
||||||
|
import com.example.medilightv2android.ui.components.BleReconnectionState
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VivamyoMonitoringView(appState: AppState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val bleManager = remember { BleManager.getInstance(context) }
|
||||||
|
val nirsManager = remember { NirsManager.instance }
|
||||||
|
val handler = remember { Handler(Looper.getMainLooper()) }
|
||||||
|
|
||||||
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
var showClearConfirm by remember { mutableStateOf(false) }
|
||||||
|
var isToggling by remember { mutableStateOf(false) }
|
||||||
|
var showDisconnectionBanner by remember { mutableStateOf(false) }
|
||||||
|
var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) }
|
||||||
|
|
||||||
|
val batteryLvl by bleManager.batteryLevel
|
||||||
|
val isConnected by bleManager.isConnected
|
||||||
|
val isStreaming by nirsManager.isStreaming
|
||||||
|
val currentOxy by nirsManager.currentOxy
|
||||||
|
val sampleCount by nirsManager.sampleCount
|
||||||
|
val isFilterEnabled by nirsManager.isFilterEnabled
|
||||||
|
|
||||||
|
fun startNirsStream() {
|
||||||
|
bleManager.onNirsMcjReceived = { data ->
|
||||||
|
if (nirsManager.isStreaming.value) {
|
||||||
|
NirsMcjPacket.parse(data)?.let { packet ->
|
||||||
|
nirsManager.processMcjPacket(packet)
|
||||||
|
}
|
||||||
|
handler.postDelayed({
|
||||||
|
if (nirsManager.isStreaming.value) bleManager.sendMcjQuery()
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bleManager.sendMcjQuery()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toggleStreaming() {
|
||||||
|
if (isToggling) return
|
||||||
|
isToggling = true
|
||||||
|
if (isStreaming) {
|
||||||
|
nirsManager.stopStreaming()
|
||||||
|
} else {
|
||||||
|
nirsManager.startStreaming()
|
||||||
|
startNirsStream()
|
||||||
|
}
|
||||||
|
handler.postDelayed({ isToggling = false }, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
bleManager.onUnexpectedDisconnect = {
|
||||||
|
reconnectionState = BleReconnectionState.DISCONNECTED
|
||||||
|
showDisconnectionBanner = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DisposableEffect(Unit) { onDispose { nirsManager.stopStreaming() } }
|
||||||
|
|
||||||
|
// Clear confirm dialog
|
||||||
|
if (showClearConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showClearConfirm = false },
|
||||||
|
title = { Text("Clear all data?") },
|
||||||
|
text = { Text("All collected NIRS data will be permanently deleted.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
nirsManager.clearData()
|
||||||
|
showClearConfirm = false
|
||||||
|
}) { Text("Clear Data", color = Color.Red) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showClearConfirm = false }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val stO2Color = when {
|
||||||
|
currentOxy.stO2 < 50 -> Color.Red
|
||||||
|
currentOxy.stO2 < 60 -> Color(0xFFFF9500)
|
||||||
|
else -> Color.Green
|
||||||
|
}
|
||||||
|
val stO2StatusText = when {
|
||||||
|
currentOxy.stO2 < 50 -> "Low"
|
||||||
|
currentOxy.stO2 < 60 -> "Caution"
|
||||||
|
currentOxy.stO2 <= 85 -> "Normal"
|
||||||
|
else -> "Check"
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White)
|
||||||
|
) {
|
||||||
|
// Top bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
nirsManager.reset()
|
||||||
|
appState.goHome()
|
||||||
|
}) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.padding(start = 8.dp)) {
|
||||||
|
Text("Vivamyo", fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Text("NIRS Monitoring", fontSize = 13.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("$batteryLvl%", fontSize = 13.sp, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(if (isConnected) Color.Green else Color.Red))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDisconnectionBanner) {
|
||||||
|
BleDisconnectionBanner(
|
||||||
|
state = reconnectionState,
|
||||||
|
onReconnect = {
|
||||||
|
reconnectionState = BleReconnectionState.RECONNECTING
|
||||||
|
bleManager.reconnectToLastDevice(
|
||||||
|
com.example.medilightv2android.services.UserStorage.getInstance(context).lastDeviceAddress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab picker
|
||||||
|
TabRow(selectedTabIndex = selectedTab, modifier = Modifier.padding(horizontal = 24.dp)) {
|
||||||
|
Tab(selected = selectedTab == 0, onClick = { selectedTab = 0 }) { Text("SpO2", modifier = Modifier.padding(12.dp)) }
|
||||||
|
Tab(selected = selectedTab == 1, onClick = { selectedTab = 1 }) { Text("Oxy/Deoxy", modifier = Modifier.padding(12.dp)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
if (selectedTab == 0) {
|
||||||
|
// SpO2 tab
|
||||||
|
BladdyIdleRiveView(
|
||||||
|
size = 120.dp,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(String.format("%.1f", currentOxy.stO2), fontSize = 56.sp, fontWeight = FontWeight.Bold, color = stO2Color)
|
||||||
|
Text("% StO2", fontSize = 18.sp, color = MlSecondaryText)
|
||||||
|
Text(stO2StatusText, fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = stO2Color,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(stO2Color.copy(alpha = 0.15f)).padding(horizontal = 12.dp, vertical = 4.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
NirsMetricsCard(nirsManager)
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text("Samples: $sampleCount", fontSize = 11.sp, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("4Hz | 60s window", fontSize = 11.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Oxy/Deoxy tab
|
||||||
|
LineChartCard("HbO2 (Oxy)", nirsManager.oxyHbO2History, Color.Red, "uM")
|
||||||
|
LineChartCard("HHb (DeOxy)", nirsManager.oxyHbHistory, Color.Blue, "uM")
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text("Samples: $sampleCount", fontSize = 11.sp, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
if (isFilterEnabled) {
|
||||||
|
Text("Filtered", fontSize = 10.sp, fontWeight = FontWeight.Bold, color = Color.White,
|
||||||
|
modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFF9966FF)).padding(horizontal = 6.dp, vertical = 2.dp))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
}
|
||||||
|
Text("4Hz | 60s window", fontSize = 11.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
NirsMetricsCard(nirsManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(horizontal = 24.dp).padding(bottom = 32.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
Button(
|
||||||
|
onClick = { showClearConfirm = true },
|
||||||
|
modifier = Modifier.weight(1f).height(50.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.1f))
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(Icons.Default.Delete, null, tint = Color.Red)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Clear", fontWeight = FontWeight.SemiBold, color = Color.Red)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Button(
|
||||||
|
onClick = { toggleStreaming() },
|
||||||
|
modifier = Modifier.weight(1f).height(50.dp),
|
||||||
|
shape = RoundedCornerShape(14.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
contentPadding = PaddingValues(),
|
||||||
|
enabled = !isToggling
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().background(
|
||||||
|
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||||
|
RoundedCornerShape(14.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
if (isStreaming) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||||
|
null, tint = Color.White
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(if (isStreaming) "Stop" else "Start", fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NirsMetricsCard(nirsManager: NirsManager) {
|
||||||
|
val oxy by nirsManager.currentOxy
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||||
|
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
MetricBox("HbO2", oxy.hbO2, "uM", Color.Red, Modifier.weight(1f))
|
||||||
|
MetricBox("HHb", oxy.hb, "uM", Color.Blue, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
MetricBox("tHb", oxy.tHb, "uM", Color(0xFF9966FF), Modifier.weight(1f))
|
||||||
|
MetricBox("StO2", oxy.stO2, "%", Color.Green, Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MetricBox(title: String, value: Double, unit: String, color: Color, modifier: Modifier = Modifier) {
|
||||||
|
Column(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(RoundedCornerShape(8.dp))
|
||||||
|
.background(MlBackground)
|
||||||
|
.padding(10.dp)
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(modifier = Modifier.size(8.dp).clip(CircleShape).background(color))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(title, fontSize = 11.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
Text(String.format("%.1f", value), fontSize = 22.sp, fontWeight = FontWeight.Bold, modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.End)
|
||||||
|
Text(unit, fontSize = 10.sp, color = MlSecondaryText, modifier = Modifier.fillMaxWidth(), textAlign = androidx.compose.ui.text.style.TextAlign.End)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LineChartCard(label: String, data: List<Double>, color: Color, unit: String) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground),
|
||||||
|
modifier = Modifier.shadow(4.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.04f))
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(modifier = Modifier.size(6.dp).clip(CircleShape).background(color))
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(label, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text(
|
||||||
|
String.format("%.1f %s", data.lastOrNull() ?: 0.0, unit),
|
||||||
|
fontSize = 10.sp, fontWeight = FontWeight.SemiBold, color = color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
if (data.isEmpty()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxWidth().height(140.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { Text("No data", fontSize = 11.sp, color = MlSecondaryText) }
|
||||||
|
} else {
|
||||||
|
Canvas(modifier = Modifier.fillMaxWidth().height(140.dp)) {
|
||||||
|
if (data.size < 2) return@Canvas
|
||||||
|
val minVal = data.min()
|
||||||
|
val maxVal = data.max()
|
||||||
|
val range = if (maxVal - minVal > 0.001) maxVal - minVal else 1.0
|
||||||
|
val stepX = size.width / (data.size - 1).toFloat()
|
||||||
|
|
||||||
|
val path = Path()
|
||||||
|
data.forEachIndexed { index, value ->
|
||||||
|
val x = index * stepX
|
||||||
|
val y = size.height - ((value - minVal) / range * size.height).toFloat()
|
||||||
|
if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||||
|
}
|
||||||
|
drawPath(path, color = color, style = Stroke(width = 3f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.onboarding
|
||||||
|
|
||||||
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.pager.HorizontalPager
|
||||||
|
import androidx.compose.foundation.pager.rememberPagerState
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
private val pages = listOf(
|
||||||
|
"Welcome to MedilightV2" to "Smart bladder monitoring\nfor better quality of life",
|
||||||
|
"NIRS & Piezo Sensors" to "Advanced dual-sensor technology\nfor accurate measurements",
|
||||||
|
"Real-time Monitoring" to "Track your bladder level\nwith instant visual feedback"
|
||||||
|
)
|
||||||
|
|
||||||
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Composable
|
||||||
|
fun OnboardingView(appState: AppState) {
|
||||||
|
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MlBackground)
|
||||||
|
) {
|
||||||
|
HorizontalPager(
|
||||||
|
state = pagerState,
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) { page ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(horizontal = 32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
BladdyIdleRiveView(size = 180.dp)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = pages[page].first,
|
||||||
|
fontSize = 24.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = pages[page].second,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = MlSecondaryText,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(2f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page indicators
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(bottom = 16.dp),
|
||||||
|
horizontalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
repeat(pages.size) { index ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.padding(horizontal = 4.dp)
|
||||||
|
.size(if (index == pagerState.currentPage) 10.dp else 8.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (index == pagerState.currentPage) MlPrimary
|
||||||
|
else MlPrimary.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (pagerState.currentPage < pages.size - 1) {
|
||||||
|
scope.launch { pagerState.animateScrollToPage(pagerState.currentPage + 1) }
|
||||||
|
} else {
|
||||||
|
appState.completeOnboarding()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp)
|
||||||
|
.padding(bottom = 48.dp)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
contentPadding = PaddingValues()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
brush = Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (pagerState.currentPage < pages.size - 1) "Next" else "Get Started",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.personalization
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.Add
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Remove
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.ble.BleManager
|
||||||
|
import com.example.medilightv2android.models.*
|
||||||
|
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PiezoPersonalizationView(appState: AppState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val bleManager = remember { BleManager.getInstance(context) }
|
||||||
|
val handler = remember { Handler(Looper.getMainLooper()) }
|
||||||
|
var currentStep by remember { mutableIntStateOf(1) }
|
||||||
|
var optimizationComplete by remember { mutableStateOf(false) }
|
||||||
|
val totalSteps = 4
|
||||||
|
|
||||||
|
fun startOptimization() {
|
||||||
|
bleManager.onPiezoDataReceived = {
|
||||||
|
optimizationComplete = true
|
||||||
|
}
|
||||||
|
bleManager.sendPiezoPowerOn(freqOption = 2, cycles = 5)
|
||||||
|
handler.postDelayed({ optimizationComplete = true }, 6000)
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.White)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { appState.backToSensorSelect() }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("Step $currentStep of $totalSteps", fontSize = 14.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Spacer(modifier = Modifier.width(20.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress bar
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp)
|
||||||
|
.height(6.dp)
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(MlPrimary.copy(alpha = 0.15f))
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(currentStep.toFloat() / totalSteps)
|
||||||
|
.fillMaxHeight()
|
||||||
|
.clip(RoundedCornerShape(4.dp))
|
||||||
|
.background(Brush.horizontalGradient(listOf(MlTeal, MlPrimary)))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Step content
|
||||||
|
when (currentStep) {
|
||||||
|
1 -> PostureStep(appState)
|
||||||
|
2 -> SensorPositionStep(appState)
|
||||||
|
3 -> MaxVolumeStep(appState)
|
||||||
|
4 -> {
|
||||||
|
LaunchedEffect(Unit) { startOptimization() }
|
||||||
|
OptimizationStep(optimizationComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Navigation button
|
||||||
|
if (currentStep < 4) {
|
||||||
|
Button(
|
||||||
|
onClick = { currentStep++ },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp)
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
contentPadding = PaddingValues()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().background(
|
||||||
|
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||||
|
RoundedCornerShape(16.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("Next", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (optimizationComplete) {
|
||||||
|
Button(
|
||||||
|
onClick = { appState.personalizationComplete() },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp)
|
||||||
|
.padding(bottom = 32.dp)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
contentPadding = PaddingValues()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().background(
|
||||||
|
Brush.horizontalGradient(listOf(MlTeal, MlPrimary)),
|
||||||
|
RoundedCornerShape(16.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text("Continue", fontSize = 18.sp, fontWeight = FontWeight.SemiBold, color = Color.White)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PostureStep(appState: AppState) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("Select Posture", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Choose your measurement position", fontSize = 14.sp, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Posture.entries.forEach { posture ->
|
||||||
|
val isSelected = appState.piezoSettings.posture == posture
|
||||||
|
SelectionCard(
|
||||||
|
title = posture.displayName,
|
||||||
|
isSelected = isSelected,
|
||||||
|
onClick = { appState.piezoSettings = appState.piezoSettings.copy(posture = posture) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SensorPositionStep(appState: AppState) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("Sensor Position", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Place the sensor on your body", fontSize = 14.sp, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.padding(horizontal = 32.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
SensorPosition.entries.forEach { position ->
|
||||||
|
val isSelected = appState.piezoSettings.sensorPosition == position
|
||||||
|
SelectionCard(
|
||||||
|
title = position.displayName,
|
||||||
|
subtitle = position.description,
|
||||||
|
isSelected = isSelected,
|
||||||
|
onClick = { appState.piezoSettings = appState.piezoSettings.copy(sensorPosition = position) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun MaxVolumeStep(appState: AppState) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
|
Text("Maximum Volume", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Set your maximum bladder capacity", fontSize = 14.sp, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
Text("${appState.piezoSettings.maxVolume}", fontSize = 56.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||||
|
Text("ml", fontSize = 20.sp, color = MlSecondaryText)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(32.dp)) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
appState.piezoSettings = appState.piezoSettings.copy(
|
||||||
|
maxVolume = (appState.piezoSettings.maxVolume - 50).coerceIn(200, 800)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Remove, null, tint = MlPrimary, modifier = Modifier.size(44.dp))
|
||||||
|
}
|
||||||
|
IconButton(onClick = {
|
||||||
|
appState.piezoSettings = appState.piezoSettings.copy(
|
||||||
|
maxVolume = (appState.piezoSettings.maxVolume + 50).coerceIn(200, 800)
|
||||||
|
)
|
||||||
|
}) {
|
||||||
|
Icon(Icons.Default.Add, null, tint = MlPrimary, modifier = Modifier.size(44.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Slider(
|
||||||
|
value = appState.piezoSettings.maxVolume.toFloat(),
|
||||||
|
onValueChange = { appState.piezoSettings = appState.piezoSettings.copy(maxVolume = it.toInt()) },
|
||||||
|
valueRange = 200f..800f,
|
||||||
|
steps = 11,
|
||||||
|
modifier = Modifier.padding(horizontal = 48.dp),
|
||||||
|
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
||||||
|
)
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 48.dp)) {
|
||||||
|
Text("200ml", fontSize = 12.sp, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("800ml", fontSize = 12.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun OptimizationStep(isComplete: Boolean) {
|
||||||
|
Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
if (isComplete) "Optimization Complete" else "Optimizing...",
|
||||||
|
fontSize = 24.sp, fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
BladdyIdleRiveView(size = 160.dp)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
if (isComplete) {
|
||||||
|
Icon(Icons.Default.CheckCircle, null, tint = MlTeal, modifier = Modifier.size(48.dp))
|
||||||
|
} else {
|
||||||
|
CircularProgressIndicator(color = MlPrimary)
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
Text("Calibrating sensor parameters", fontSize = 14.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SelectionCard(title: String, subtitle: String? = null, isSelected: Boolean, onClick: () -> Unit) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.then(if (isSelected) Modifier.border(2.dp, MlPrimary, RoundedCornerShape(16.dp)) else Modifier),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(if (subtitle != null) 48.dp else 56.dp)
|
||||||
|
.clip(RoundedCornerShape(if (subtitle != null) 12.dp else 14.dp))
|
||||||
|
.background(if (isSelected) MlPrimary else MlPrimary.copy(alpha = 0.1f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(title.take(3), color = if (isSelected) Color.White else MlPrimary, fontWeight = FontWeight.Bold, fontSize = 12.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(title, fontSize = if (subtitle != null) 16.sp else 18.sp, fontWeight = FontWeight.SemiBold)
|
||||||
|
subtitle?.let {
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Text(it, fontSize = 13.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
Icon(Icons.Default.CheckCircle, null, tint = MlPrimary, modifier = Modifier.size(24.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.pin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.services.UserStorage
|
||||||
|
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
|
||||||
|
enum class PinMode { SETUP, ENTER }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun PinView(appState: AppState, mode: PinMode, context: android.content.Context) {
|
||||||
|
var enteredPin by remember { mutableStateOf("") }
|
||||||
|
var confirmPin by remember { mutableStateOf("") }
|
||||||
|
var isConfirming by remember { mutableStateOf(false) }
|
||||||
|
var errorMessage by remember { mutableStateOf("") }
|
||||||
|
val pinLength = 4
|
||||||
|
|
||||||
|
val currentPin = if (isConfirming) confirmPin else enteredPin
|
||||||
|
|
||||||
|
val title = when (mode) {
|
||||||
|
PinMode.SETUP -> if (isConfirming) "Re-enter PIN" else "Set PIN"
|
||||||
|
PinMode.ENTER -> "Enter PIN"
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addDigit(digit: Int) {
|
||||||
|
errorMessage = ""
|
||||||
|
if (isConfirming) {
|
||||||
|
if (confirmPin.length >= pinLength) return
|
||||||
|
confirmPin += digit.toString()
|
||||||
|
if (confirmPin.length == pinLength) {
|
||||||
|
// Verify setup
|
||||||
|
if (confirmPin == enteredPin) {
|
||||||
|
appState.pinSet(enteredPin)
|
||||||
|
} else {
|
||||||
|
errorMessage = "PINs don't match. Try again."
|
||||||
|
enteredPin = ""
|
||||||
|
confirmPin = ""
|
||||||
|
isConfirming = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (enteredPin.length >= pinLength) return
|
||||||
|
enteredPin += digit.toString()
|
||||||
|
if (enteredPin.length == pinLength) {
|
||||||
|
when (mode) {
|
||||||
|
PinMode.SETUP -> isConfirming = true
|
||||||
|
PinMode.ENTER -> {
|
||||||
|
val stored = UserStorage.getInstance(context).pin
|
||||||
|
if (enteredPin == stored) {
|
||||||
|
appState.pinVerified()
|
||||||
|
} else {
|
||||||
|
errorMessage = "Incorrect PIN"
|
||||||
|
enteredPin = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteDigit() {
|
||||||
|
if (isConfirming) {
|
||||||
|
if (confirmPin.isNotEmpty()) confirmPin = confirmPin.dropLast(1)
|
||||||
|
} else {
|
||||||
|
if (enteredPin.isNotEmpty()) enteredPin = enteredPin.dropLast(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MlBackground),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
BladdyIdleRiveView(size = 100.dp)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Text(title, fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
|
if (errorMessage.isNotEmpty()) {
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(errorMessage, fontSize = 14.sp, color = Color.Red)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Dot indicators
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
repeat(pinLength) { index ->
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(16.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(
|
||||||
|
if (index < currentPin.length) MlPrimary
|
||||||
|
else Color.Gray.copy(alpha = 0.3f)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Shuffled keypad
|
||||||
|
ShuffledKeypad(
|
||||||
|
onDigitTap = { addDigit(it) },
|
||||||
|
onDelete = { deleteDigit() }
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.pin
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.Backspace
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.ui.theme.MlCardBackground
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShuffledKeypad(
|
||||||
|
onDigitTap: (Int) -> Unit,
|
||||||
|
onDelete: () -> Unit
|
||||||
|
) {
|
||||||
|
val digits = (1..9).toList() + listOf(0)
|
||||||
|
|
||||||
|
LazyVerticalGrid(
|
||||||
|
columns = GridCells.Fixed(3),
|
||||||
|
modifier = Modifier.padding(horizontal = 40.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
userScrollEnabled = false
|
||||||
|
) {
|
||||||
|
// Digits 1-9
|
||||||
|
items(9) { index ->
|
||||||
|
KeyButton(digit = digits[index], onClick = { onDigitTap(digits[index]) })
|
||||||
|
}
|
||||||
|
// Empty space
|
||||||
|
item { Spacer(modifier = Modifier.height(60.dp)) }
|
||||||
|
// Digit 0
|
||||||
|
item { KeyButton(digit = 0, onClick = { onDigitTap(0) }) }
|
||||||
|
// Delete
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(60.dp)
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(Color.Gray.copy(alpha = 0.1f))
|
||||||
|
.clickable(onClick = onDelete),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.Backspace,
|
||||||
|
contentDescription = "Delete",
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun KeyButton(digit: Int, onClick: () -> Unit) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.height(60.dp)
|
||||||
|
.shadow(4.dp, RoundedCornerShape(14.dp), spotColor = Color.Black.copy(alpha = 0.06f))
|
||||||
|
.clip(RoundedCornerShape(14.dp))
|
||||||
|
.background(MlCardBackground)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "$digit",
|
||||||
|
fontSize = 28.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.registration
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun RegistrationView(appState: AppState) {
|
||||||
|
var step by remember { mutableIntStateOf(1) }
|
||||||
|
var firstName by remember { mutableStateOf("") }
|
||||||
|
var lastName by remember { mutableStateOf("") }
|
||||||
|
var ageText by remember { mutableStateOf("") }
|
||||||
|
var heightText by remember { mutableStateOf("") }
|
||||||
|
var weightText by remember { mutableStateOf("") }
|
||||||
|
val totalSteps = 3
|
||||||
|
|
||||||
|
val canProceed = when (step) {
|
||||||
|
1 -> firstName.trim().isNotEmpty() && lastName.trim().isNotEmpty()
|
||||||
|
2 -> {
|
||||||
|
val age = ageText.toIntOrNull() ?: 0
|
||||||
|
age in 1..120
|
||||||
|
}
|
||||||
|
3 -> {
|
||||||
|
val h = heightText.toDoubleOrNull() ?: 0.0
|
||||||
|
val w = weightText.toDoubleOrNull() ?: 0.0
|
||||||
|
h in 50.0..250.0 && w in 10.0..300.0
|
||||||
|
}
|
||||||
|
else -> false
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MlBackground)
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 24.dp, vertical = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
if (step > 1) {
|
||||||
|
IconButton(onClick = { step-- }) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", tint = MlPrimary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("$step/$totalSteps", fontSize = 16.sp, fontWeight = FontWeight.Medium, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
BladdyIdleRiveView(
|
||||||
|
size = 120.dp,
|
||||||
|
modifier = Modifier.align(Alignment.CenterHorizontally)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Content
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(20.dp)
|
||||||
|
) {
|
||||||
|
when (step) {
|
||||||
|
1 -> {
|
||||||
|
Text("What's your name?", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
|
RegistrationTextField("First Name", firstName, { firstName = it })
|
||||||
|
RegistrationTextField("Last Name", lastName, { lastName = it })
|
||||||
|
}
|
||||||
|
2 -> {
|
||||||
|
Text("How old are you?", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
|
RegistrationTextField("Age", ageText, { ageText = it }, KeyboardType.Number)
|
||||||
|
}
|
||||||
|
3 -> {
|
||||||
|
Text("Body measurements", fontSize = 24.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
RegistrationTextField("Height", heightText, { heightText = it }, KeyboardType.Decimal, "cm")
|
||||||
|
}
|
||||||
|
Box(modifier = Modifier.weight(1f)) {
|
||||||
|
RegistrationTextField("Weight", weightText, { weightText = it }, KeyboardType.Decimal, "kg")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// Next button
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (step < totalSteps) {
|
||||||
|
step++
|
||||||
|
} else {
|
||||||
|
appState.user = appState.user.copy(
|
||||||
|
firstName = firstName,
|
||||||
|
lastName = lastName,
|
||||||
|
age = ageText.toIntOrNull() ?: 0,
|
||||||
|
heightCm = heightText.toDoubleOrNull() ?: 0.0,
|
||||||
|
weightKg = weightText.toDoubleOrNull() ?: 0.0
|
||||||
|
)
|
||||||
|
appState.completeRegistration()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = canProceed,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 32.dp)
|
||||||
|
.padding(bottom = 48.dp)
|
||||||
|
.height(56.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
||||||
|
contentPadding = PaddingValues()
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
brush = if (canProceed) Brush.horizontalGradient(listOf(MlTeal, MlPrimary))
|
||||||
|
else Brush.horizontalGradient(listOf(Color.Gray.copy(alpha = 0.4f), Color.Gray.copy(alpha = 0.4f))),
|
||||||
|
shape = RoundedCornerShape(16.dp)
|
||||||
|
),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = if (step < totalSteps) "Next" else "Complete",
|
||||||
|
fontSize = 18.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun RegistrationTextField(
|
||||||
|
placeholder: String,
|
||||||
|
value: String,
|
||||||
|
onValueChange: (String) -> Unit,
|
||||||
|
keyboardType: KeyboardType = KeyboardType.Text,
|
||||||
|
suffix: String? = null
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = onValueChange,
|
||||||
|
placeholder = { Text(placeholder) },
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||||
|
suffix = suffix?.let { { Text(it, color = MlSecondaryText) } },
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.background(MlInputBackground, RoundedCornerShape(12.dp))
|
||||||
|
.border(1.dp, MlInputBorder.copy(alpha = 0.3f), RoundedCornerShape(12.dp)),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
colors = OutlinedTextFieldDefaults.colors(
|
||||||
|
unfocusedBorderColor = Color.Transparent,
|
||||||
|
focusedBorderColor = MlInputBorder
|
||||||
|
),
|
||||||
|
singleLine = true
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.sensorselect
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.example.medilightv2android.AppState
|
||||||
|
import com.example.medilightv2android.models.SensorMode
|
||||||
|
import com.example.medilightv2android.ui.components.BladdyIdleRiveView
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SensorSelectView(appState: AppState) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MlBackground),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
BladdyIdleRiveView(size = 140.dp)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Text("Select Sensor", fontSize = 28.sp, fontWeight = FontWeight.Bold)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Text("Choose your measurement mode", fontSize = 16.sp, color = MlSecondaryText)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(32.dp))
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.padding(horizontal = 32.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
|
) {
|
||||||
|
SensorCard(
|
||||||
|
mode = SensorMode.NIRS,
|
||||||
|
title = "NIRS",
|
||||||
|
description = "Near-Infrared Spectroscopy\nTissue oxygenation monitoring",
|
||||||
|
gradientColors = listOf(Color(0xFF00DBA1), Color(0xFF008FFF)),
|
||||||
|
iconText = "NIRS",
|
||||||
|
onClick = { appState.selectSensor(SensorMode.NIRS) }
|
||||||
|
)
|
||||||
|
|
||||||
|
SensorCard(
|
||||||
|
mode = SensorMode.PIEZO,
|
||||||
|
title = "Piezo",
|
||||||
|
description = "Ultrasound Echo\nBladder volume measurement",
|
||||||
|
gradientColors = listOf(Color(0xFF8F44FC), Color(0xFFF04A8F)),
|
||||||
|
iconText = "Piezo",
|
||||||
|
onClick = { appState.selectSensor(SensorMode.PIEZO) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SensorCard(
|
||||||
|
mode: SensorMode,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
gradientColors: List<Color>,
|
||||||
|
iconText: String,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.shadow(8.dp, RoundedCornerShape(16.dp), spotColor = Color.Black.copy(alpha = 0.06f))
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = MlCardBackground)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(20.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(72.dp)
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(Brush.linearGradient(gradientColors)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(iconText, color = Color.White, fontWeight = FontWeight.Bold, fontSize = 14.sp)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(20.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(title, fontSize = 20.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(description, fontSize = 14.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MlSecondaryText
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">medilightv2android</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
|
||||||
|
<style name="Theme.Medilightv2android" parent="android:Theme.Material.Light.NoActionBar" />
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?><!--
|
||||||
|
Sample backup rules file; uncomment and customize as necessary.
|
||||||
|
See https://developer.android.com/guide/topics/data/autobackup
|
||||||
|
for details.
|
||||||
|
Note: This file is ignored for devices older than API 31
|
||||||
|
See https://developer.android.com/about/versions/12/backup-restore
|
||||||
|
-->
|
||||||
|
<full-backup-content>
|
||||||
|
<!--
|
||||||
|
<include domain="sharedpref" path="."/>
|
||||||
|
<exclude domain="sharedpref" path="device.xml"/>
|
||||||
|
-->
|
||||||
|
</full-backup-content>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.example.medilightv2android
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[versions]
|
||||||
|
agp = "8.13.2"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.17.0"
|
||||||
|
junit = "4.13.2"
|
||||||
|
junitVersion = "1.3.0"
|
||||||
|
espressoCore = "3.7.0"
|
||||||
|
lifecycleRuntimeKtx = "2.10.0"
|
||||||
|
activityCompose = "1.12.2"
|
||||||
|
composeBom = "2024.09.00"
|
||||||
|
gson = "2.11.0"
|
||||||
|
securityCrypto = "1.1.0-alpha06"
|
||||||
|
rive = "9.4.2"
|
||||||
|
startupRuntime = "1.2.0"
|
||||||
|
lifecycleViewmodelCompose = "2.10.0"
|
||||||
|
|
||||||
|
[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-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleViewmodelCompose" }
|
||||||
|
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-compose-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
|
||||||
|
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
|
||||||
|
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-compose-material-icons = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" }
|
||||||
|
androidx-security-crypto = { group = "androidx.security", name = "security-crypto", version.ref = "securityCrypto" }
|
||||||
|
rive-android = { group = "app.rive", name = "rive-android", version.ref = "rive" }
|
||||||
|
androidx-startup-runtime = { group = "androidx.startup", name = "startup-runtime", version.ref = "startupRuntime" }
|
||||||
|
androidx-compose-foundation = { group = "androidx.compose.foundation", name = "foundation" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
|
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
#Mon Mar 09 11:24:11 KST 2026
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Copyright © 2015 the original 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.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: Apache-2.0
|
||||||
|
#
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX generated by Gradle.
|
||||||
|
#
|
||||||
|
# Important for running:
|
||||||
|
#
|
||||||
|
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||||
|
# noncompliant, but you have some other compliant shell such as ksh or
|
||||||
|
# bash, then to run this script, type that shell name before the whole
|
||||||
|
# command line, like:
|
||||||
|
#
|
||||||
|
# ksh Gradle
|
||||||
|
#
|
||||||
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
|
# requires all of these POSIX shell features:
|
||||||
|
# * functions;
|
||||||
|
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||||
|
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||||
|
# * compound commands having a testable exit status, especially «case»;
|
||||||
|
# * various built-in commands including «command», «set», and «ulimit».
|
||||||
|
#
|
||||||
|
# Important for patching:
|
||||||
|
#
|
||||||
|
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||||
|
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||||
|
#
|
||||||
|
# The "traditional" practice of packing multiple parameters into a
|
||||||
|
# space-separated string is a well documented source of bugs and security
|
||||||
|
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||||
|
# options in "$@", and eventually passing that to Java.
|
||||||
|
#
|
||||||
|
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||||
|
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||||
|
# see the in-line comments for details.
|
||||||
|
#
|
||||||
|
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||||
|
# Darwin, MinGW, and NonStop.
|
||||||
|
#
|
||||||
|
# (3) This script is generated from the Groovy template
|
||||||
|
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||||
|
# within the Gradle project.
|
||||||
|
#
|
||||||
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
|
#
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
|
||||||
|
# Need this for daisy-chained symlinks.
|
||||||
|
while
|
||||||
|
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||||
|
[ -h "$app_path" ]
|
||||||
|
do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in #(
|
||||||
|
/*) app_path=$link ;; #(
|
||||||
|
*) app_path=$APP_HOME$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# This is normally unused
|
||||||
|
# shellcheck disable=SC2034
|
||||||
|
APP_BASE_NAME=${0##*/}
|
||||||
|
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
|
||||||
|
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD=maximum
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
} >&2
|
||||||
|
|
||||||
|
# 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 ;; #(
|
||||||
|
MSYS* | MINGW* ) msys=true ;; #(
|
||||||
|
NONSTOP* ) nonstop=true ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH="\\\"\\\""
|
||||||
|
|
||||||
|
|
||||||
|
# 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
|
||||||
|
if ! command -v java >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
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
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||||
|
case $MAX_FD in #(
|
||||||
|
max*)
|
||||||
|
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
|
warn "Could not query maximum file descriptor limit"
|
||||||
|
esac
|
||||||
|
case $MAX_FD in #(
|
||||||
|
'' | soft) :;; #(
|
||||||
|
*)
|
||||||
|
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||||
|
# shellcheck disable=SC2039,SC3045
|
||||||
|
ulimit -n "$MAX_FD" ||
|
||||||
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, stacking in reverse order:
|
||||||
|
# * args from the command line
|
||||||
|
# * the main class name
|
||||||
|
# * -classpath
|
||||||
|
# * -D...appname settings
|
||||||
|
# * --module-path (only if needed)
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||||
|
|
||||||
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
|
if "$cygwin" || "$msys" ; then
|
||||||
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
|
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||||
|
|
||||||
|
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||||
|
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
for arg do
|
||||||
|
if
|
||||||
|
case $arg in #(
|
||||||
|
-*) false ;; # don't mess with options #(
|
||||||
|
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||||
|
[ -e "$t" ] ;; #(
|
||||||
|
*) false ;;
|
||||||
|
esac
|
||||||
|
then
|
||||||
|
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||||
|
fi
|
||||||
|
# Roll the args list around exactly as many times as the number of
|
||||||
|
# args, so each arg winds up back in the position where it started, but
|
||||||
|
# possibly modified.
|
||||||
|
#
|
||||||
|
# NB: a `for` loop captures its iteration list before it begins, so
|
||||||
|
# changing the positional parameters here affects neither the number of
|
||||||
|
# iterations, nor the values presented in `arg`.
|
||||||
|
shift # remove old arg
|
||||||
|
set -- "$@" "$arg" # push replacement arg
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
# 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"'
|
||||||
|
|
||||||
|
# Collect all arguments for the java command:
|
||||||
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
|
# and any embedded shellness will be escaped.
|
||||||
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
|
set -- \
|
||||||
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
|
"$@"
|
||||||
|
|
||||||
|
# Stop when "xargs" is not available.
|
||||||
|
if ! command -v xargs >/dev/null 2>&1
|
||||||
|
then
|
||||||
|
die "xargs is not available"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use "xargs" to parse quoted args.
|
||||||
|
#
|
||||||
|
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||||
|
#
|
||||||
|
# In Bash we could simply go:
|
||||||
|
#
|
||||||
|
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||||
|
# set -- "${ARGS[@]}" "$@"
|
||||||
|
#
|
||||||
|
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||||
|
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||||
|
# character that might be a shell metacharacter, then use eval to reverse
|
||||||
|
# that process (while maintaining the separation between arguments), and wrap
|
||||||
|
# the whole thing up as a single "set" statement.
|
||||||
|
#
|
||||||
|
# This will of course break if any of these variables contains a newline or
|
||||||
|
# an unmatched quote.
|
||||||
|
#
|
||||||
|
|
||||||
|
eval "set -- $(
|
||||||
|
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||||
|
xargs -n1 |
|
||||||
|
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||||
|
tr '\n' ' '
|
||||||
|
)" '"$@"'
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@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
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@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=.
|
||||||
|
@rem This is normally unused
|
||||||
|
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% equ 0 goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
|
echo. 1>&2
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
|
echo. 1>&2
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=
|
||||||
|
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if %ERRORLEVEL% equ 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!
|
||||||
|
set EXIT_CODE=%ERRORLEVEL%
|
||||||
|
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||||
|
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
@@ -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 = "medilightv2android"
|
||||||
|
include(":app")
|
||||||
|
|
||||||