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