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 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 10:16:59 +09:00
parent 99c58382b4
commit 9feaae1939
4 changed files with 89 additions and 10 deletions
@@ -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
}
@@ -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 = {
@@ -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
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)
)
}
@@ -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<Runnable?>(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<Runnable?>(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,12 +164,17 @@ 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
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))
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) {