package com.medithings.vesiscan import android.content.Context import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import com.medithings.vesiscan.ble.BleManager import com.medithings.vesiscan.models.* import com.medithings.vesiscan.services.* enum class AppScreen { ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN, PIEZO_PERSONALIZATION, PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY, PIEZO_MONITORING, MEASUREMENT_HISTORY, VOIDING_DIARY, REMINDER_SETTINGS, CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼) CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture HOSPITAL_MODE, // ★ Dev 모드 한정 — 병원 임상 측정 (주파수×cycle 자동 순회) ANCHOR_ALIGN, // ★ Dev 모드 한정 — 부착 위치 정렬 (병원 임상 전 단계) FIRMWARE_UPDATE // ★ 2026-08-03 (cloud-mvp 672e679 이식) — VBT 프로브 firmware DFU } class AppState(private val context: Context) { var currentScreen by mutableStateOf(AppScreen.ONBOARDING) /** * DEVICE_SCAN 을 끝내고(연결 성공이든 뒤로가기든) 돌아갈 화면. * * 기기 연결 화면은 여러 곳에서 불려 오는데, 원래는 `ClinicalSessionStore.inClinicalFlow` * 불리언 하나로 "임상 흐름이면 CLINICAL_HOME, 아니면 HOME" 만 갈랐다. 부르는 곳이 * 셋 이상이 되면 불리언을 더 만들게 되고, 그 순간부터 두 깃발이 어긋나기 시작한다. * * 그래서 **어디로 돌아갈지를 값으로** 들고 있는다. null 이면 기존 규칙대로 간다 — * 기존 호출부는 하나도 건드리지 않았다. * * 한 번 쓰면 비운다([consumeConnectReturn]). 안 비우면 다음에 다른 경로로 들어온 * 연결 화면까지 엉뚱한 곳으로 보낸다. */ /** * 정렬로 정한 부착 위치(cm). 병원 임상 모드가 표시·기록에 쓴다. * * 자세·용량이 바뀌어도 이 위치는 **고정**이라 한 환자 동안 유지된다. * 환자가 바뀜면 다시 정렬해야 하므로 병원 모드에서 환자명을 바꿀 때 비운다. */ var anchorCm: Int? by mutableStateOf(null) /** * 병원 임상 환자명. 화면 상태가 아니라 여기 둔다. * * 정렬 화면을 다녀오면 [ui.views.clinical.HospitalModeView] 가 파기되어 remember 가 * 초기화된다. 그러면 돌아와서 이름을 다시 치는 순간 "환자가 바뀌었다"로 보여 * 방금 맞춘 정렬 결과까지 지워진다. 정렬 화면도 이 이름으로 저장 폴더를 잡는다. */ var hospitalPatient: String by mutableStateOf("") var connectReturnScreen: AppScreen? = null /** 복귀 화면을 꺼내며 지운다. 없으면 null. */ fun consumeConnectReturn(): AppScreen? = connectReturnScreen.also { connectReturnScreen = null } var user by mutableStateOf(User()) var sensorMode by mutableStateOf(SensorMode.PIEZO) var bladderLevel by mutableStateOf(BladderLevel()) /** * BLE 연결 상태 — **BleManager 실시간 값을 즉시 반영** (2026-08-03 fix · cloud-mvp 9c82a50). * * 이전: `mutableStateOf(false)` 로 stale snapshot 저장 → BLE 가 끊겨도 홈 화면이 * "연결됨" 으로 표시되고, Start 누르면 실제 PIEZO_MONITORING 에서 * "연결 해제" 로 표시되는 불일치 문제. * * 현재: getter 가 BleManager.isConnected 를 읽음 → Compose 가 자동 재구성. * 기존 코드의 setter 는 no-op 로 유지 (호환). */ var isDeviceConnected: Boolean get() = isDemoMode || bleManager.isConnected.value set(_) { /* no-op · 실제 상태는 BleManager 가 관리 */ } var isDemoMode by mutableStateOf(false) var isDevMode by mutableStateOf(false) var piezoSettings by mutableStateOf(PiezoSettings()) var catheterCount = mutableIntStateOf(0) // Placement 진입 경로 분기용 — Monitoring에서 재진입 시 true, Personalization에서 진입 시 false var placementFromMonitoring by mutableStateOf(false) private val storage = UserStorage.getInstance(context) private val historyStore = MeasurementHistoryStore.getInstance(context) private val notificationService = NotificationService.getInstance(context) private val bleManager = BleManager.getInstance(context) private val reminderService = ReminderService.getInstance(context) init { loadState() } fun loadState() { user = storage.user sensorMode = storage.sensorMode bladderLevel = bladderLevel.copy( currentLevel = storage.bladderLevel, catheterizationThreshold = storage.catheterizationLevel ) piezoSettings = storage.piezoSettings catheterCount.intValue = storage.catheterCount currentScreen = when { !storage.hasCompletedOnboarding -> AppScreen.ONBOARDING !storage.isRegistered -> AppScreen.REGISTRATION !storage.hasPin -> AppScreen.PIN_SETUP else -> AppScreen.PIN_ENTRY } } fun completeOnboarding() { storage.hasCompletedOnboarding = true storage.hasConsented = true currentScreen = AppScreen.REGISTRATION } fun completeConsent() { storage.hasConsented = true storage.consentTimestamp = System.currentTimeMillis() currentScreen = AppScreen.REGISTRATION } fun completeRegistration() { storage.user = user storage.isRegistered = true currentScreen = AppScreen.PIN_SETUP } fun goHome() { // 회의 결정: 실제 BLE 연결은 HOME에서도 유지. 다음 Start 시 설정/scan skip하고 // 바로 PiezoMonitoring으로 진입할 수 있도록. // Demo 모드는 가짜 연결이므로 isDeviceConnected reset. if (isDemoMode) isDeviceConnected = false isDemoMode = false currentScreen = AppScreen.HOME } fun enterDemoMode() { isDemoMode = true isDeviceConnected = true currentScreen = AppScreen.PIEZO_MONITORING } fun startFromHome() { sensorMode = SensorMode.PIEZO storage.sensorMode = SensorMode.PIEZO // 회의 결정: 이미 연결된 상태이면 설정/Placement skip하고 바로 도넛차트. // 연결되지 않은 상태에서만 DeviceScan부터 일련의 과정 진행. currentScreen = if (isDeviceConnected) AppScreen.PIEZO_MONITORING else AppScreen.DEVICE_SCAN } fun selectSensor(mode: SensorMode) { sensorMode = mode storage.sensorMode = mode currentScreen = AppScreen.DEVICE_SCAN } /** * 기기 연결 화면의 **화면 안 뒤로 화살표**. * * ⚠ 시스템 뒤로가기(MainActivity BackHandler)와 목적지가 다르다 — 그쪽은 * `inClinicalFlow` 를 보고 CLINICAL_HOME 으로 가는데 여기는 늘 HOME 이다. * 기존부터 그랬고 지금 손대면 임상 흐름의 동작이 바뀌므로 그대로 둔다. * 다만 복귀처를 지정한 화면([connectReturnScreen])은 두 경로가 같아야 하므로 * 여기서도 먼저 확인한다. */ fun backToSensorSelect() { consumeConnectReturn()?.let { currentScreen = it; return } currentScreen = AppScreen.HOME } fun deviceConnected() { isDeviceConnected = true // 부른 화면이 복귀처를 지정했으면 그리로 (병원 임상 모드 등). consumeConnectReturn()?.let { currentScreen = it; return } // 임상 모드에서 BLE 연결 시 ClinicalHome으로 복귀 (Personalization 우회) if (com.medithings.vesiscan.services.ClinicalSessionStore.inClinicalFlow) { currentScreen = AppScreen.CLINICAL_HOME return } currentScreen = AppScreen.PIEZO_PERSONALIZATION } fun personalizationComplete() { storage.piezoSettings = piezoSettings placementFromMonitoring = false // 회의 #8: AutoScanInterval을 Personalization 페이지에 통합 — 별도 화면 폐기. currentScreen = AppScreen.PLACEMENT_GUIDE } /** Persist piezoSettings (called from in-session settings save). */ fun savePiezoSettingsNow() { // Re-assign to trigger the mutableStateOf delegate so any composables // observing piezoSettings recompose with the latest value, then persist // to disk so the change survives process death. piezoSettings = piezoSettings.copy() storage.piezoSettings = piezoSettings } fun placementComplete() { bladderLevel = BladderLevel(currentLevel = 0, catheterizationThreshold = bladderLevel.catheterizationThreshold) storage.bladderLevel = 0 placementFromMonitoring = false currentScreen = AppScreen.PIEZO_MONITORING } fun enterPlacementFromMonitoring() { placementFromMonitoring = true currentScreen = AppScreen.PLACEMENT_GUIDE } /** Clinical Mode alignment session 진입 — PlacementGuide 로 이동. * ClinicalSessionStore.startMeasurement(mode=ALIGNMENT) 가 먼저 호출돼 있어야 함. */ fun enterAlignmentSession() { placementFromMonitoring = false currentScreen = AppScreen.PLACEMENT_GUIDE } fun backFromPlacementGuide() { // 회의 #8: AutoScan을 Personalization으로 통합했으므로 Placement Back은 // 바로 Personalization으로 (이전 AUTO_SCAN_SETUP 단계 폐기). currentScreen = if (placementFromMonitoring) AppScreen.PIEZO_MONITORING else AppScreen.PIEZO_PERSONALIZATION placementFromMonitoring = false } fun pinSet(pin: String) { storage.pin = pin storage.hasPin = true currentScreen = AppScreen.HOME } fun pinVerified() { currentScreen = AppScreen.HOME } fun incrementLevel() { // 데모 · 1단계 증가 — 신규 함수로 위임 (cloud-mvp deb7b17 이식). val currentVolume = estimatedVolume val stepVolume = piezoSettings.maxVolume.toDouble() / BladderLevel.MAX_LEVEL updateLevelFromMeasurement(currentVolume + stepVolume, recordHistory = true) } /** * Phase A · 실측 경로 통합 (GUARDIAN-01, 2026-08-03 · cloud-mvp deb7b17 이식). * * 이전: PiezoMonitoringView 3곳 bladderLevel 직접 대입 → * NotificationService · MeasurementHistoryStore 우회 → 실사용 시 알람 미발생. * 이제: 실측/데모 경로 모두 이 함수로 일원화. 알람 · 이력 자동. * * @param volumeMl 실측 부피 (mL) * @param recordHistory 이력 저장 여부 (기본 true · auto-scan 갱신 시 false 로 폭탄 방지) */ fun updateLevelFromMeasurement(volumeMl: Double, recordHistory: Boolean = true) { val previousUrgency = bladderLevel.urgency val maxVol = piezoSettings.maxVolume.toDouble() val pct = if (maxVol > 0) (volumeMl / maxVol).coerceAtMost(1.0) else 0.0 val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(0, BladderLevel.MAX_LEVEL) bladderLevel = bladderLevel.copy(currentLevel = level) storage.bladderLevel = bladderLevel.currentLevel if (recordHistory) { val record = MeasurementRecord( eventType = MeasurementEventType.MEASUREMENT, level = bladderLevel.currentLevel, estimatedVolume = volumeMl.toInt(), urgencyLevel = bladderLevel.urgency, fillPercentage = fillPercentage, ) historyStore.addRecord(record) } val newUrgency = bladderLevel.urgency val urgencyChanged = newUrgency != previousUrgency if (urgencyChanged) { notificationService.sendUrgencyNotification(newUrgency, volumeMl.toInt()) } // Phase D (GUARDIAN-01 · 2026-08-03) — ClinicalEventBus emit. runCatching { com.medithings.vesiscan.telemetry.ClinicalEventBus.emit( com.medithings.vesiscan.telemetry.BvEvent( ts = System.currentTimeMillis(), sessionId = com.medithings.vesiscan.telemetry.TelemetryHash.sessionId( bleManager.connectedDeviceName.value.ifEmpty { "unknown" } ), severity = when (newUrgency) { com.medithings.vesiscan.models.UrgencyLevel.URGENT -> com.medithings.vesiscan.telemetry.Severity.CRITICAL com.medithings.vesiscan.models.UrgencyLevel.WARNING -> com.medithings.vesiscan.telemetry.Severity.WARN else -> com.medithings.vesiscan.telemetry.Severity.INFO }, volumeMl = volumeMl.toInt(), urgency = newUrgency, bvMethod = com.medithings.vesiscan.managers.GreenZoneConstants.bvMethod.name, isTransition = urgencyChanged, prevUrgency = previousUrgency.takeIf { urgencyChanged }, ) ) } } 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() reminderService.reschedule() if (catheterCount.intValue > 0) { catheterCount.intValue-- storage.catheterCount = catheterCount.intValue } } fun showHistory() { currentScreen = AppScreen.MEASUREMENT_HISTORY } fun backToMonitoring() { currentScreen = AppScreen.PIEZO_MONITORING } fun addCatheterCount(amount: Int = 1) { catheterCount.intValue += amount storage.catheterCount = catheterCount.intValue } fun updateCatheterizationThreshold(threshold: Int) { bladderLevel = bladderLevel.copy(catheterizationThreshold = threshold) storage.catheterizationLevel = threshold } fun savePiezoSettings() { storage.piezoSettings = piezoSettings } 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 } }