Files
VesiscanClinicalAndroid/app/src/main/java/com/medithings/vesiscan/AppState.kt
T
dw.jang 64026b122d feat(clinical): standing 확인 측정 — supine 부착 위치를 standing 에서 그대로 써도 되는지 두 조건으로 가른다
알고리즘 팀 스펙(ej.won, 2026-10-13 11:29). 임상 순서 supine → standing → sitting. supine 100%
뒤 자세를 Standing 으로 바꾸면 병원 화면 정렬 카드 위에 확인 카드가 뜬다. supine 부착 위치에서
정렬 조건(2.3MHz · c3 · 20 cycle)으로 한 번 재고 순서대로 본다:
  ① CH3 가 방광 영역을 잡는가 — 정렬 후보 자격과 같은 2단 게이트(mean-scan O + 검출률 ≥ 0.8)
  ② 지금 nch ≥ supine 정렬 nch − 1
둘 다 OK → standing 부착 위치를 supine 것으로 승계(정렬 건너뜀). 아니면 standing 정렬로 안내.
막지 않는다 — 안내만. sitting 은 해당 없음(ej.won: 각도 때문에 논외).

- StandingConfirm.judge(rec, supineNch, channels, bv) — 순수 판정. nch·CH3·검출률은 정렬과 같은
  AnchorGuide.measure 로 낸다(비교 대상이 정렬 nch 라 다른 식이면 안 된다).
- 기준 nch: 정렬이 "임상 측정으로 넘어가기"에서 기록하는 그 자리에서 잰 nch 를 AnchorState.nch
  로 같이 넘긴다(알고리즘·폴백·이대로 진행 모두 같다). 기준이 없으면 승계하지 않는다.
- 승계하면 AnchorState(cm=supine, basis=supine 것, refChannels=지금 채널, carriedFrom=SUPINE).
  실패하면 위치를 비워 정렬 카드가 "미정렬"로 보이고 판정만 남긴다.
- 기록: standing 정렬 폴더 align_{cm}cm_standing_confirm.csv + IMU(시도마다 _rN), align_result.json
  `standing_confirm`(+승계면 anchor_cm·carried_from), 601 세션 phase=standing_confirm
  (standing_confirm_frames), run 매니페스트 anchor_nch · anchor_carried_from · anchor_standing_confirm.
  정렬 요약이 통째로 덮어쓸 때 standing_confirm 은 보존한다("확인 안 돼서 정렬했다"는 맥락).
- 테스트: StandingConfirmTest 6건. 전체 235건 통과.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-13 11:41:46 +09:00

474 lines
21 KiB
Kotlin

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(프로브 DFU)는 2026-10-13 에 뺐다 — 펌웨어 갱신은 같은 폰의 사용자 앱이 맡는다.
// 번들 zip·버전 판정을 두 앱에 이중으로 두면 어긋나기만 했다(임상 번들이 1.0.0+5 로 낡아 있었다).
}
class AppState(private val context: Context) {
var currentScreen by mutableStateOf(AppScreen.ONBOARDING)
/**
* DEVICE_SCAN 을 끝내고(연결 성공이든 뒤로가기든) 돌아갈 화면.
*
* 기기 연결 화면은 여러 곳에서 불려 오는데, 원래는 `ClinicalSessionStore.inClinicalFlow`
* 불리언 하나로 "임상 흐름이면 CLINICAL_HOME, 아니면 HOME" 만 갈랐다. 부르는 곳이
* 셋 이상이 되면 불리언을 더 만들게 되고, 그 순간부터 두 깃발이 어긋나기 시작한다.
*
* 그래서 **어디로 돌아갈지를 값으로** 들고 있는다. null 이면 기존 규칙대로 간다 —
* 기존 호출부는 하나도 건드리지 않았다.
*
* 한 번 쓰면 비운다([consumeConnectReturn]). 안 비우면 다음에 다른 경로로 들어온
* 연결 화면까지 엉뚱한 곳으로 보낸다.
*/
/**
* 한 자세의 부착 위치와 딸린 것. **자세마다 따로 산다**(2026-10-01 — 임상 프로토콜이
* 자세마다 정렬을 다시 한다). 네 값은 같이 넣고 같이 비운다 — 하나만 남으면 그 순간부터
* 어긋난다(환자가 바뀌었는데 재부착 확인이 ✓ 로 남는 식).
*
* @property cm 정렬로 정한 부착 위치. 그 자세의 전 용량에서 고정.
* @property basis 누가 정했는지. null = 정렬을 거치지 않았다. 정렬은 실제 인체에서 끝까지
* 안 맞을 수 있어 진행을 막지 않으므로, 알고리즘이 고른 자리와 사람이 고른 자리를
* 나중에 가를 유일한 단서다.
* @property refChannels 정렬 좌우 확인 창(없으면 확인 측정)에서 잡힌 채널 CH0~3 —
* 재부착 확인([com.medithings.vesiscan.managers.ReattachCheck])의 기준. null = 기준 없음.
* @property reattach 재부착 확인 결과. null = 아직 안 했다.
*/
data class AnchorState(
val cm: Int? = null,
val basis: com.medithings.vesiscan.managers.AnchorBasis? = null,
val refChannels: List<Boolean>? = null,
val reattach: com.medithings.vesiscan.managers.ReattachCheck.Result? = null,
/** 정렬이 기록에 남긴 그 자리에서 잰 중앙 채널 수(nch) — standing 확인 측정의 기준(2026-10-13). */
val nch: Int? = null,
/** 이 자세의 위치가 다른 자세에서 승계된 것이면 그 자세(standing ← supine). null = 직접 정렬. */
val carriedFrom: com.medithings.vesiscan.models.ClinicalPosture? = null,
/** standing 확인 측정 결과([com.medithings.vesiscan.managers.StandingConfirm]). null = 아직. */
val standingConfirm: com.medithings.vesiscan.managers.StandingConfirm.Result? = null,
)
/** 자세별 부착 위치. [clinicalPosture] 가 가리키는 항목을 아래 접근자가 읽고 쓴다. */
val anchors = androidx.compose.runtime.mutableStateMapOf<
com.medithings.vesiscan.models.ClinicalPosture, AnchorState>()
private fun anchorNow(): AnchorState = anchors[clinicalPosture] ?: AnchorState()
private fun updateAnchor(f: (AnchorState) -> AnchorState) { anchors[clinicalPosture] = f(anchorNow()) }
/** 지금 자세의 부착 위치(cm). 병원 임상 모드가 표시·기록에 쓴다. */
var anchorCm: Int?
get() = anchorNow().cm
set(v) = updateAnchor { it.copy(cm = v) }
/** 지금 자세의 [AnchorState.basis]. */
var anchorBasis: com.medithings.vesiscan.managers.AnchorBasis?
get() = anchorNow().basis
set(v) = updateAnchor { it.copy(basis = v) }
/** 지금 자세의 [AnchorState.refChannels]. */
var anchorRefChannels: List<Boolean>?
get() = anchorNow().refChannels
set(v) = updateAnchor { it.copy(refChannels = v) }
/** 지금 자세의 [AnchorState.reattach]. */
var anchorReattach: com.medithings.vesiscan.managers.ReattachCheck.Result?
get() = anchorNow().reattach
set(v) = updateAnchor { it.copy(reattach = v) }
/** 지금 자세의 [AnchorState.nch]. */
var anchorNch: Int?
get() = anchorNow().nch
set(v) = updateAnchor { it.copy(nch = v) }
/** 지금 자세의 [AnchorState.carriedFrom]. */
var anchorCarriedFrom: com.medithings.vesiscan.models.ClinicalPosture?
get() = anchorNow().carriedFrom
set(v) = updateAnchor { it.copy(carriedFrom = v) }
/** 지금 자세의 [AnchorState.standingConfirm]. */
var anchorStandingConfirm: com.medithings.vesiscan.managers.StandingConfirm.Result?
get() = anchorNow().standingConfirm
set(v) = updateAnchor { it.copy(standingConfirm = v) }
/** 다른 자세의 부착 상태 — standing 확인 측정이 supine 것을 읽는다(2026-10-13). */
fun anchorOf(p: com.medithings.vesiscan.models.ClinicalPosture): AnchorState = anchors[p] ?: AnchorState()
/** 다른 자세의 부착 상태를 통째로 쓴다 — 승계(standing ← supine)에 쓴다. */
fun setAnchor(p: com.medithings.vesiscan.models.ClinicalPosture, s: AnchorState) { anchors[p] = s }
/** 지금 자세의 부착 위치와 딸린 것을 비운다. */
fun clearAnchor() { anchors.remove(clinicalPosture) }
/**
* Supine 정렬 확인 측정의 IMU 평균 기울기(도) — Sitting 요구 범위의 기준각 m
* ([com.medithings.vesiscan.models.PostureTilt] 참조, 2026-10-01). null = 아직 없음.
* 환자가 바뀌면 비운다. 앱을 다시 켜면 그날 Supine 정렬 요약(`confirm_tilt_deg_mean`)에서
* 되살린다([com.medithings.vesiscan.services.HospitalRunStore.readConfirmTilt]).
*/
var supineTiltBaseline: Float? by mutableStateOf(null)
/** 전 자세를 비운다 — 환자가 바뀌거나 프로브가 바뀔 때(빔 각도가 달라 정렬이 무효). */
fun clearAllAnchors() {
anchors.clear()
supineTiltBaseline = null
}
/**
* 병원 임상 자세. **정렬 화면과 병원 모드가 같은 값을 봐야 한다.**
*
* BV 계산이 이 값을 본다(`supine` → nch=2 빔 clamp 건너뛰기, F83). 화면마다 따로
* 들고 있으면 같은 프로브·같은 자리에서 **정렬 BV 와 임상 BV 가 다른 식으로**
* 계산된다 — 실제로 정렬이 `supine` 을 안 넘겨 그렇게 돌고 있었다(2026-09-10).
*
* 정렬 화면과 병원 모드 어느 쪽에서든 고를 수 있다(2026-09-29 정렬 화면에 칩 추가 —
* 병원 화면에서 정렬이 자세보다 위에 있어 자세 없이 들어오는 것이 정상 경로다).
*/
var clinicalPosture: com.medithings.vesiscan.models.ClinicalPosture by
mutableStateOf(com.medithings.vesiscan.models.ClinicalPosture.SUPINE)
/**
* 병원 임상 환자명. 화면 상태가 아니라 여기 둔다.
*
* 정렬 화면을 다녀오면 [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 로 유지 (호환).
*/
/**
* 화면이 "연결됨"으로 쓰는 값 — **[BleManager.isServiceReady] 를 본다.**
*
* 종전에는 `isConnected` 였다. 그런데 링크만 붙고 서비스 탐색·CCCD 가 끝나지 않은
* **반쪽 연결**이 있다. 그 상태에서 `isConnected` 는 true 이고 화면은 전부 "연결됨"으로
* 표시했는데 배터리·IMU·측정 어느 것도 오지 않았다 — 사용자는 앱이 멈춘 줄 안다
* (2026-09-10 현장 재연 ⑤~⑦).
*
* **"붙었다"가 아니라 "쓸 수 있다"가 사용자에게 의미 있는 상태다.**
*/
var isDeviceConnected: Boolean
get() = isDemoMode || bleManager.isServiceReady.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
}
}