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 sensorMode by mutableStateOf(SensorMode.PIEZO)
var bladderLevel by mutableStateOf(BladderLevel()) var bladderLevel by mutableStateOf(BladderLevel())
var isDeviceConnected by mutableStateOf(false) var isDeviceConnected by mutableStateOf(false)
var isDemoMode by mutableStateOf(false)
var piezoSettings by mutableStateOf(PiezoSettings()) var piezoSettings by mutableStateOf(PiezoSettings())
var catheterCount = mutableIntStateOf(0) var catheterCount = mutableIntStateOf(0)
@@ -66,11 +67,22 @@ class AppState(private val context: Context) {
fun goHome() { fun goHome() {
NirsManager.instance.stopStreaming() NirsManager.instance.stopStreaming()
NirsManager.instance.reset() NirsManager.instance.reset()
bleManager.disconnect() if (!isDemoMode) bleManager.disconnect()
isDeviceConnected = false isDeviceConnected = false
isDemoMode = false
currentScreen = AppScreen.HOME currentScreen = AppScreen.HOME
} }
fun enterDemoMode() {
isDemoMode = true
isDeviceConnected = true
currentScreen = if (sensorMode == SensorMode.NIRS) {
AppScreen.VIVAMYO_MONITORING
} else {
AppScreen.PIEZO_MONITORING
}
}
fun startFromHome() { fun startFromHome() {
currentScreen = AppScreen.SENSOR_SELECT 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 // Scan button
Button( Button(
onClick = { onClick = {
@@ -48,6 +48,7 @@ fun PiezoMonitoringView(appState: AppState) {
var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) } var reconnectionState by remember { mutableStateOf(BleReconnectionState.DISCONNECTED) }
var isAutoMeasuring by remember { mutableStateOf(false) } var isAutoMeasuring by remember { mutableStateOf(false) }
val isDemoMode = appState.isDemoMode
val urgency = appState.bladderLevel.urgency val urgency = appState.bladderLevel.urgency
val batteryLvl by bleManager.batteryLevel val batteryLvl by bleManager.batteryLevel
val isConnected by bleManager.isConnected val isConnected by bleManager.isConnected
@@ -179,13 +180,18 @@ fun PiezoMonitoringView(appState: AppState) {
} }
// Battery // 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) Text("$batteryLvl%", fontSize = 13.sp, color = MlSecondaryText)
}
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Box( Box(
modifier = Modifier modifier = Modifier
.size(10.dp) .size(10.dp)
.clip(CircleShape) .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.ble.BleManager
import com.example.medilightv2android.managers.NirsManager import com.example.medilightv2android.managers.NirsManager
import com.example.medilightv2android.models.NirsMcjPacket 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.BladdyIdleRiveView
import com.example.medilightv2android.ui.components.BleDisconnectionBanner import com.example.medilightv2android.ui.components.BleDisconnectionBanner
import com.example.medilightv2android.ui.components.BleReconnectionState import com.example.medilightv2android.ui.components.BleReconnectionState
import com.example.medilightv2android.ui.theme.* import com.example.medilightv2android.ui.theme.*
import kotlin.random.Random
@Composable @Composable
fun VivamyoMonitoringView(appState: AppState) { fun VivamyoMonitoringView(appState: AppState) {
@@ -56,6 +58,11 @@ fun VivamyoMonitoringView(appState: AppState) {
val sampleCount by nirsManager.sampleCount val sampleCount by nirsManager.sampleCount
val isFilterEnabled by nirsManager.isFilterEnabled 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 mcjRetryCount = remember { mutableIntStateOf(0) }
var mcjRetryRunnable by remember { mutableStateOf<Runnable?>(null) } var mcjRetryRunnable by remember { mutableStateOf<Runnable?>(null) }
@@ -108,6 +115,39 @@ fun VivamyoMonitoringView(appState: AppState) {
handler.postDelayed(initialRetry, 2000) 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() { fun stopNirsStream() {
// 1. Stop polling chain & clear callback first // 1. Stop polling chain & clear callback first
nirsManager.stopStreaming() nirsManager.stopStreaming()
@@ -124,12 +164,17 @@ fun VivamyoMonitoringView(appState: AppState) {
if (isToggling) return if (isToggling) return
isToggling = true isToggling = true
if (isStreaming) { if (isStreaming) {
stopNirsStream() if (isDemoMode) stopDemoStream() else stopNirsStream()
} else { } else {
bleManager.stopBatteryPolling() // Pause battery polling during streaming if (isDemoMode) {
nirsManager.startStreaming()
startDemoStream()
} else {
bleManager.stopBatteryPolling()
nirsManager.startStreaming() nirsManager.startStreaming()
startNirsStream() startNirsStream()
} }
}
handler.postDelayed({ isToggling = false }, 1000) handler.postDelayed({ isToggling = false }, 1000)
} }
@@ -141,7 +186,8 @@ fun VivamyoMonitoringView(appState: AppState) {
} }
DisposableEffect(Unit) { onDispose { DisposableEffect(Unit) { onDispose {
if (nirsManager.isStreaming.value) stopNirsStream() if (isDemoMode) { stopDemoStream() }
else if (nirsManager.isStreaming.value) stopNirsStream()
else { cancelMcjRetry(); bleManager.onNirsMcjReceived = null } else { cancelMcjRetry(); bleManager.onNirsMcjReceived = null }
} } } }
@@ -196,9 +242,14 @@ fun VivamyoMonitoringView(appState: AppState) {
Text("NIRS Monitoring", fontSize = 13.sp, color = MlSecondaryText) Text("NIRS Monitoring", fontSize = 13.sp, color = MlSecondaryText)
} }
Spacer(modifier = Modifier.weight(1f)) 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) Text("$batteryLvl%", fontSize = 13.sp, color = MlSecondaryText)
}
Spacer(modifier = Modifier.width(8.dp)) 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) { if (showDisconnectionBanner) {