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>
This commit is contained in:
2026-03-09 17:27:00 +09:00
commit a8532917b4
73 changed files with 5662 additions and 0 deletions
+43
View File
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.
Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+3
View File
@@ -0,0 +1,3 @@
<resources>
<string name="app_name">medilightv2android</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Medilightv2android" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older 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>