From 9feaae1939a7d78eb9835cbc0dc190adf89c2d68 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 25 Mar 2026 10:16:59 +0900 Subject: [PATCH] feat: add demo mode for testing without BLE device Add "Demo Mode" button in device scan screen that bypasses BLE connection and calibration. NIRS monitoring generates simulated random data at 4Hz. Both monitoring views show DEMO badge in top bar. Co-Authored-By: Claude Opus 4.6 --- .../example/medilightv2android/AppState.kt | 14 +++- .../ui/views/connection/DeviceScanView.kt | 10 +++ .../views/monitoring/PiezoMonitoringView.kt | 10 ++- .../views/monitoring/VivamyoMonitoringView.kt | 65 +++++++++++++++++-- 4 files changed, 89 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index a4f4189..8ecd0c6 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -22,6 +22,7 @@ class AppState(private val context: Context) { var sensorMode by mutableStateOf(SensorMode.PIEZO) var bladderLevel by mutableStateOf(BladderLevel()) var isDeviceConnected by mutableStateOf(false) + var isDemoMode by mutableStateOf(false) var piezoSettings by mutableStateOf(PiezoSettings()) var catheterCount = mutableIntStateOf(0) @@ -66,11 +67,22 @@ class AppState(private val context: Context) { fun goHome() { NirsManager.instance.stopStreaming() NirsManager.instance.reset() - bleManager.disconnect() + if (!isDemoMode) bleManager.disconnect() isDeviceConnected = false + isDemoMode = false currentScreen = AppScreen.HOME } + fun enterDemoMode() { + isDemoMode = true + isDeviceConnected = true + currentScreen = if (sensorMode == SensorMode.NIRS) { + AppScreen.VIVAMYO_MONITORING + } else { + AppScreen.PIEZO_MONITORING + } + } + fun startFromHome() { currentScreen = AppScreen.SENSOR_SELECT } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt index 663fcdd..7009d06 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt @@ -251,6 +251,16 @@ fun DeviceScanView(appState: AppState) { } } + // Demo mode button + TextButton( + onClick = { appState.enterDemoMode() }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + ) { + Text("Demo Mode (No Device)", fontSize = 14.sp, color = MlSecondaryText) + } + // Scan button Button( onClick = { diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 768669e..f79cb4d 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -48,6 +48,7 @@ fun PiezoMonitoringView(appState: AppState) { var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) } var isAutoMeasuring by remember { mutableStateOf(false) } + val isDemoMode = appState.isDemoMode val urgency = appState.bladderLevel.urgency val batteryLvl by bleManager.batteryLevel val isConnected by bleManager.isConnected @@ -179,13 +180,18 @@ fun PiezoMonitoringView(appState: AppState) { } // Battery - Text("$batteryLvl%", fontSize = 13.sp, color = MlSecondaryText) + if (isDemoMode) { + Text("DEMO", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, + modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFFFF9500)).padding(horizontal = 6.dp, vertical = 2.dp)) + } else { + 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) + .background(if (isDemoMode || isConnected) Color.Green else Color.Red) ) } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt index 75ffb71..6dcd5df 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/VivamyoMonitoringView.kt @@ -31,10 +31,12 @@ 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.models.NirsOxyResult 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.* +import kotlin.random.Random @Composable fun VivamyoMonitoringView(appState: AppState) { @@ -56,6 +58,11 @@ fun VivamyoMonitoringView(appState: AppState) { val sampleCount by nirsManager.sampleCount val isFilterEnabled by nirsManager.isFilterEnabled + val isDemoMode = appState.isDemoMode + var demoRunnable by remember { mutableStateOf(null) } + var demoBaseHbO2 by remember { mutableStateOf(12.0) } + var demoBaseHb by remember { mutableStateOf(8.0) } + var mcjRetryCount = remember { mutableIntStateOf(0) } var mcjRetryRunnable by remember { mutableStateOf(null) } @@ -108,6 +115,39 @@ fun VivamyoMonitoringView(appState: AppState) { handler.postDelayed(initialRetry, 2000) } + fun startDemoStream() { + val tick = object : Runnable { + override fun run() { + if (!nirsManager.isStreaming.value) return + demoBaseHbO2 += Random.nextDouble(-0.3, 0.3) + demoBaseHb += Random.nextDouble(-0.2, 0.2) + val hbO2 = demoBaseHbO2.coerceIn(5.0, 25.0) + val hb = demoBaseHb.coerceIn(3.0, 15.0) + demoBaseHbO2 = hbO2 + demoBaseHb = hb + val tHb = hbO2 + hb + val stO2 = if (tHb > 0) (hbO2 / tHb * 100.0) else 0.0 + + nirsManager.currentOxy.value = NirsOxyResult(hbO2, hb, tHb, stO2) + nirsManager.sampleCount.intValue++ + nirsManager.oxyHbO2History.add(hbO2) + nirsManager.oxyHbHistory.add(hb) + if (nirsManager.oxyHbO2History.size > 240) nirsManager.oxyHbO2History.removeAt(0) + if (nirsManager.oxyHbHistory.size > 240) nirsManager.oxyHbHistory.removeAt(0) + + handler.postDelayed(this, 250) + } + } + demoRunnable = tick + handler.post(tick) + } + + fun stopDemoStream() { + demoRunnable?.let { handler.removeCallbacks(it) } + demoRunnable = null + nirsManager.stopStreaming() + } + fun stopNirsStream() { // 1. Stop polling chain & clear callback first nirsManager.stopStreaming() @@ -124,11 +164,16 @@ fun VivamyoMonitoringView(appState: AppState) { if (isToggling) return isToggling = true if (isStreaming) { - stopNirsStream() + if (isDemoMode) stopDemoStream() else stopNirsStream() } else { - bleManager.stopBatteryPolling() // Pause battery polling during streaming - nirsManager.startStreaming() - startNirsStream() + if (isDemoMode) { + nirsManager.startStreaming() + startDemoStream() + } else { + bleManager.stopBatteryPolling() + nirsManager.startStreaming() + startNirsStream() + } } handler.postDelayed({ isToggling = false }, 1000) } @@ -141,7 +186,8 @@ fun VivamyoMonitoringView(appState: AppState) { } DisposableEffect(Unit) { onDispose { - if (nirsManager.isStreaming.value) stopNirsStream() + if (isDemoMode) { stopDemoStream() } + else if (nirsManager.isStreaming.value) stopNirsStream() else { cancelMcjRetry(); bleManager.onNirsMcjReceived = null } } } @@ -196,9 +242,14 @@ fun VivamyoMonitoringView(appState: AppState) { Text("NIRS Monitoring", fontSize = 13.sp, color = MlSecondaryText) } Spacer(modifier = Modifier.weight(1f)) - Text("$batteryLvl%", fontSize = 13.sp, color = MlSecondaryText) + if (isDemoMode) { + Text("DEMO", fontSize = 11.sp, fontWeight = FontWeight.Bold, color = Color.White, + modifier = Modifier.clip(RoundedCornerShape(4.dp)).background(Color(0xFFFF9500)).padding(horizontal = 6.dp, vertical = 2.dp)) + } else { + 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)) + Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(if (isDemoMode || isConnected) Color.Green else Color.Red)) } if (showDisconnectionBanner) {