From b97d3d805271b2d9210ceff32eb2d656029614d2 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Sat, 9 May 2026 17:21:11 +0900 Subject: [PATCH] =?UTF-8?q?feat:=2010=EA=B1=B4=20UI=20=ED=94=BC=EB=93=9C?= =?UTF-8?q?=EB=B0=B1=20+=20Auto=20fail=20=ED=8C=9D=EC=97=85=20+=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1 Sensor Detached 상단 문구 삭제 (urgency 메시지 항상 유지) #2 Max Volume 5초 간격 갱신 (displayMaxVolumeMl) #3 Fill/Level → Current Measurement/Fill #4 detach 시 팝업→Placement 이동 (showRealignPrompt) #5 Auto 2ch 이상 미수신 연속 5회 → 팝업 (바로 이동 아님) #6 팝업 텍스트 "Sensor Position Issue" + 안내 문구 #7 Step2 진입 시 "Checking left/right balance..." #8 detach 후 waitingForStart=true → Start 버튼 표시 #9 초기/재시작 안내 시 화살표 제거 (directionIcon="") #10 Raise/Lower 텍스트 → "Raise the sensor up" 등 자연어 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../views/monitoring/PiezoMonitoringView.kt | 33 +++-- .../ui/views/monitoring/PlacementGuideView.kt | 129 +++++++++++------- 2 files changed, 105 insertions(+), 57 deletions(-) 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 f17cc9e..83fa14b 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 @@ -81,6 +81,8 @@ fun PiezoMonitoringView(appState: AppState) { var lastVolumeMl by remember { mutableStateOf(0.0) } var wasDetached by remember { mutableStateOf(false) } var maxVolumeMl by remember { mutableStateOf(0.0) } + var displayMaxVolumeMl by remember { mutableStateOf(0.0) } // 5초 간격 갱신용 + var lastMaxUpdateMs by remember { mutableStateOf(0L) } var isSpotInProgress by remember { mutableStateOf(false) } val spotVolumes = remember { mutableListOf() } val measureDoneSignal = remember { mutableStateOf(kotlinx.coroutines.CompletableDeferred()) } @@ -330,9 +332,18 @@ fun PiezoMonitoringView(appState: AppState) { } // Auto 연속 실패 → UI에서 처리 - val autoFailed = isAutoMeasuring && (finalRawVol == null || finalRawVol <= 0 || !allChannelsReceived) + // 채널 2개 이상 미수신 또는 BV 계산 불가 + val centerWallsFinal = (0..3).count { allWalls[it] != null } + val autoFailed = isAutoMeasuring && (centerWallsFinal < 2 || finalRawVol == null || finalRawVol <= 0) val autoSucceeded = isAutoMeasuring && finalRawVol != null && finalRawVol > 0 && allChannelsReceived + // displayMaxVolumeMl 5초 간격 갱신 + val nowMs = System.currentTimeMillis() + if (maxVolumeMl != displayMaxVolumeMl && nowMs - lastMaxUpdateMs >= 5000) { + displayMaxVolumeMl = maxVolumeMl + lastMaxUpdateMs = nowMs + } + if (lastVolumeMl > 0 && !isSpotInProgress) { val maxVol = appState.piezoSettings.maxVolume.toDouble() val pct = if (maxVol > 0) (lastVolumeMl / maxVol).coerceAtMost(1.0) else 0.0 @@ -343,9 +354,10 @@ fun PiezoMonitoringView(appState: AppState) { if (autoSucceeded) autoFailCount = 0 if (autoFailed) { autoFailCount++ - if (autoFailCount >= 3) { + if (autoFailCount >= 5) { isAutoMeasuring = false autoVolumeWindow.clear() + autoFailCount = 0 showRealignPrompt = true } } @@ -843,9 +855,9 @@ fun PiezoMonitoringView(appState: AppState) { Arrangement.spacedBy(if (isCompact) 32.dp else 40.dp) ) { - // Status badge (미부착 시 경고) - val statusText = if (wasDetached) "Sensor detached" else urgency.message - val statusColor = if (wasDetached) Color.Red else urgency.accentColor + // Status badge (항상 urgency 메시지) + val statusText = urgency.message + val statusColor = urgency.accentColor Text( text = statusText, fontSize = 14.sp, @@ -901,7 +913,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(modifier = Modifier.height(4.dp)) val volFontSize = if (appState.isDevMode) 18.sp else 24.sp Text( - if (maxVolumeMl > 0) "${"%.0f".format(maxVolumeMl)} ml" else "${appState.estimatedVolume} ml", + if (displayMaxVolumeMl > 0) "${"%.0f".format(displayMaxVolumeMl)} ml" else "${appState.estimatedVolume} ml", fontSize = volFontSize, fontWeight = FontWeight.Bold, color = urgency.accentColor, @@ -915,8 +927,9 @@ fun PiezoMonitoringView(appState: AppState) { val fillPct = if (maxVol > 0 && maxVolumeMl > 0) (maxVolumeMl / maxVol * 100).coerceAtMost(100.0) else 0.0 val levelVal = if (maxVol > 0 && maxVolumeMl > 0) ((maxVolumeMl / maxVol) * BladderLevel.MAX_LEVEL).toInt().coerceIn(0, BladderLevel.MAX_LEVEL) else 0 Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + val currentMeasurement = if (lastVolumeMl > 0) "%.0f".format(lastVolumeMl) else "—" + InfoCard("Measurement", currentMeasurement, "ml", urgency.accentColor, Modifier.weight(1f)) InfoCard("Fill", if (fillPct > 0) "%.0f".format(fillPct) else "—", "%", urgency.accentColor, Modifier.weight(1f)) - InfoCard("Level", if (levelVal > 0) "$levelVal/${BladderLevel.MAX_LEVEL}" else "—", "", urgency.accentColor, Modifier.weight(1f)) } // Control buttons: Void/Catheterize / Auto / Spot @@ -988,7 +1001,7 @@ fun PiezoMonitoringView(appState: AppState) { attempts++ measure() while (isMeasuring) { kotlinx.coroutines.delay(100) } - kotlinx.coroutines.delay(800) + kotlinx.coroutines.delay(100) // throttle(800ms)이 간격 보장 } Log.d("PiezoMonitor", "SPOT attempts=$attempts results=${spotVolumes.size}") if (spotVolumes.size >= 3) { @@ -1313,8 +1326,8 @@ fun PiezoMonitoringView(appState: AppState) { if (showRealignPrompt) { AlertDialog( onDismissRequest = { showRealignPrompt = false }, - title = { Text("Sensor Alignment Required") }, - text = { Text("Measurement failed multiple times. Please re-align the sensor.") }, + title = { Text("Sensor Position Issue") }, + text = { Text("The sensor may have shifted from the optimal position. Please re-run Sensor Alignment to ensure accurate measurements.") }, confirmButton = { Button(onClick = { showRealignPrompt = false diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 4dd9810..d978b01 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -61,6 +61,7 @@ fun PlacementGuideView(appState: AppState) { var isScanning by remember { mutableStateOf(false) } var isContinuousMode by remember { mutableStateOf(false) } // Start 버튼으로 시작 var waitingForStart by remember { mutableStateOf(true) } // 초기 안내 상태 + var startPressedAtMs by remember { mutableStateOf(0L) } val channelDetected = remember { mutableStateListOf(false, false, false, false, false, false) } val channelUrineLen = remember { mutableStateListOf(0, 0, 0, 0, 0, 0) } val isSweepMode = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode == @@ -75,7 +76,8 @@ fun PlacementGuideView(appState: AppState) { var scanCount by remember { mutableIntStateOf(0) } var isLocked by remember { mutableStateOf(false) } var greenLockedAtMs by remember { mutableStateOf(0L) } // GREEN 진입 시각 - val greenHoldMs = 4000L // GREEN 후 최소 4초 유지 + val greenHoldMs = 7000L // GREEN 후 최소 7초 유지 + var greenFailCount by remember { mutableIntStateOf(0) } // GREEN 이탈 디바운스 var lastChannelData by remember { mutableStateOf>(emptyList()) } var lastWallResults by remember { mutableStateOf?>>(List(6) { null }) } var debounceCount by remember { mutableIntStateOf(0) } @@ -354,7 +356,7 @@ fun PlacementGuideView(appState: AppState) { modifier = Modifier .size(width = 192.dp, height = 120.dp) .align(Alignment.Center) - .offset(x = 0.dp, y = 110.dp), + .offset(x = 0.dp, y = 95.dp), contentScale = androidx.compose.ui.layout.ContentScale.Fit, alpha = 0.95f ) @@ -574,10 +576,13 @@ fun PlacementGuideView(appState: AppState) { val detachResult = if (fullCh.size >= 6) DetachmentDetection.detect(buffers) else null if (detachResult != null && detachResult.isDetached) { - directionHint = if (isSweepMode) "Sensor detached. Place near navel" - else "Sensor detached. Place above pubic bone" - directionIcon = "exclamationmark.triangle" + directionHint = "Sensor detached. Place above pubic bone, then press Start" + directionIcon = "" // 화살표 없음 placementScore = 0 + isContinuousMode = false + waitingForStart = true + placementPhase = PlacementPhase.VERTICAL + isLocked = false if (lastLedMode != 4) { bleManager.sendLedMode(4); lastLedMode = 4 } bleManager.debugLogger.warn("DETACHED mean=${"%.1f".format(detachResult.meanAbs)} std=${"%.1f".format(detachResult.stdVal)}") Log.w("PlacementGuide", "DETACHED mean=${ @@ -621,11 +626,22 @@ fun PlacementGuideView(appState: AppState) { val now = System.currentTimeMillis() val elapsed = now - lastHintChangeMs // GREEN 진입/이탈 및 phase 전환은 즉시, 같은 phase 내 힌트만 디바운스 (1.5초) + // GREEN hold 체크 (디바운스보다 먼저) + val greenHeld = isLocked && (now - greenLockedAtMs < greenHoldMs) + val wasGreen = isLocked val isNowGreen = guideResult.isPass - val greenChanged = wasGreen != isNowGreen + // GREEN hold 중에는 이탈 무시 + val greenChanged = if (greenHeld) { + !wasGreen && isNowGreen // 진입만 허용, 이탈은 무시 + } else { + wasGreen != isNowGreen + } val phaseChanged = guideResult.nextPhase != placementPhase - if (greenChanged || phaseChanged || (debounceCount >= 2 && elapsed >= 1500)) { + // Start 후 5초간 같은 phase 내 힌트는 즉시, phase 전환은 3초 유지 + val justStarted = (now - startPressedAtMs) < 5000 + val hintUpdate = justStarted && !phaseChanged // 같은 step 내 힌트만 즉시 + if (hintUpdate || greenChanged || (phaseChanged && !greenHeld && debounceCount >= 2 && elapsed >= 3000) || (!phaseChanged && !greenHeld && debounceCount >= 2 && elapsed >= 1500)) { directionHint = guideResult.hint directionIcon = guideResult.icon placementScore = guideResult.score @@ -634,29 +650,36 @@ fun PlacementGuideView(appState: AppState) { if (!guideResult.cv.isNaN()) lastCv = guideResult.cv } - // GREEN hold: 한번 optimal 진입 후 5초간 유지 (미세 변화 무시) - val greenHeld = isLocked && (now - greenLockedAtMs < greenHoldMs) + // GREEN hold 중이면 힌트 강제 유지 + if (greenHeld) { + directionHint = "Optimal position!" + directionIcon = "checkmark.circle" + placementPhase = PlacementPhase.GREEN + if (!guideResult.cv.isNaN()) lastCv = guideResult.cv + } - if (guideResult.isPass || greenHeld) { - if (!isLocked) greenLockedAtMs = now // 최초 진입 시각 기록 + if (guideResult.isPass) { + if (!isLocked) greenLockedAtMs = now isLocked = true + greenFailCount = 0 // 성공 → 실패 카운트 리셋 if (lastLedMode != 6) { bleManager.sendLedMode(6); lastLedMode = 6 } - // GREEN hold 중이면 힌트도 optimal 유지 - if (greenHeld && !guideResult.isPass) { + bleManager.debugLogger.info("PLACEMENT_GREEN cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)}") + } else if (isLocked && !greenHeld) { + // hold 지남 + 실패 → 3회 연속 실패해야 이탈 + greenFailCount++ + if (greenFailCount < 3) { + // 아직 이탈 안 함 — GREEN 유지 directionHint = "Optimal position!" directionIcon = "checkmark.circle" placementPhase = PlacementPhase.GREEN - } - bleManager.debugLogger.info("PLACEMENT_GREEN cv=${"%.3f".format(guideResult.cv)} lr_dev=${"%.3f".format(guideResult.lrDev)} score=${guideResult.score} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)}") - } else { - val wasGreenBefore = isLocked - isLocked = false - greenLockedAtMs = 0L - if (lastLedMode != 5) { bleManager.sendLedMode(5); lastLedMode = 5 } - // GREEN에서 이탈 → 스캔 중지, 재시작 대기 - if (wasGreenBefore) { + } else { + // 3회 연속 실패 → 진짜 이탈 + isLocked = false + greenLockedAtMs = 0L + greenFailCount = 0 + if (lastLedMode != 5) { bleManager.sendLedMode(5); lastLedMode = 5 } directionHint = "Position lost. Place sensor above pubic bone, then press Start" - directionIcon = "arrow.up" + directionIcon = "" // 화살표 없음 placementScore = 10 placementPhase = PlacementPhase.VERTICAL lastHintChangeMs = now @@ -666,7 +689,11 @@ fun PlacementGuideView(appState: AppState) { maxCenterSeen = 0; stableAtMax = 0; boundaryHit = false sweepLastDetected = 0; sweepEverSawAll = false rTracker.reset() + bleManager.debugLogger.info("PLACEMENT_GREEN_EXIT after 3 consecutive fails") } + } else { + // GREEN 아닌 상태에서 실패 + if (lastLedMode != 5) { bleManager.sendLedMode(5); lastLedMode = 5 } bleManager.debugLogger.info("PLACEMENT_${guideResult.phase.name} score=${guideResult.score} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)} hint=${guideResult.hint}") } } @@ -716,6 +743,23 @@ fun PlacementGuideView(appState: AppState) { onClick = { waitingForStart = false isContinuousMode = true + // 전체 상태 리셋 + debounceCount = 0 + lastHintChangeMs = 0L + scanCount = 0 + startPressedAtMs = System.currentTimeMillis() + placementPhase = PlacementPhase.VERTICAL + isLocked = false + greenLockedAtMs = 0L + greenFailCount = 0 + placementScore = 0 + maxCenterSeen = 0; stableAtMax = 0; boundaryHit = false + sweepLastDetected = 0; sweepEverSawAll = false + for (i in 0 until 6) { channelDetected[i] = false; channelUrineLen[i] = 0 } + lastWallResults = List(6) { null } + rTracker.reset() + currentRMm = null; bestRMm = 0.0 + sagittalArgMaxCh = null; lateralOffsetMm = null }, modifier = Modifier .fillMaxWidth() @@ -1151,7 +1195,7 @@ private fun computePlacementGuide( val detectedCount = detected.count { it } if (detectedCount == 0) { - return PlacementGuideResult("Raise the sensor ↑", "arrow.up", 0, + return PlacementGuideResult("Raise the sensor up", "arrow.up", 0, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } @@ -1209,7 +1253,7 @@ private fun computePlacementGuide( val lrHint = when { l4 > 0 && l5 > 0 && l4 > l5 -> "Move left ←" l4 > 0 && l5 > 0 && l5 > l4 -> "Move right →" - else -> "Checking lateral..." + else -> "Checking left/right balance..." } val lrIcon = when { l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.left" @@ -1245,13 +1289,13 @@ private fun computePlacementGuide( val hint: String; val icon: String when { (ch0det || ch1det) && !ch2det && !ch3det -> { - hint = "Raise the sensor ↑"; icon = "arrow.up" + hint = "Raise the sensor up"; icon = "arrow.up" } (ch3det || ch2det) && !ch0det && !ch1det -> { - hint = "Lower the sensor ↓"; icon = "arrow.down" + hint = "Lower the sensor down"; icon = "arrow.down" } else -> { - hint = "Raise the sensor ↑"; icon = "arrow.up" + hint = "Raise the sensor up"; icon = "arrow.up" } } return PlacementGuideResult(hint, icon, centerDetected * 10, @@ -1295,17 +1339,12 @@ private fun computePlacementGuide( val ch4ok = detected.getOrElse(4) { false } val ch5ok = detected.getOrElse(5) { false } - // 좌우도 이미 균등하면 LATERAL 건너뛰고 바로 GREEN - if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 5) { - return PlacementGuideResult("Position OK! Final check...", - "checkmark.circle", 65, - phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN) - } + // 항상 LATERAL 단계를 거침 (Step 1→2→3 순서 강제) val lrHint = when { l4 > 0 && l5 > 0 && l4 > l5 -> "Move left ←" l4 > 0 && l5 > 0 && l5 > l4 -> "Move right →" - else -> "Checking lateral..." + else -> "Checking left/right balance..." } val lrIcon = when { l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.left" @@ -1326,21 +1365,17 @@ private fun computePlacementGuide( val l5 = urineLens.getOrElse(5) { 0 } val ch4ok = detected.getOrElse(4) { false } val ch5ok = detected.getOrElse(5) { false } - if (ch4ok && ch5ok && kotlin.math.abs(l4 - l5) <= 5) { - return PlacementGuideResult("Position OK! Final check...", - "checkmark.circle", 60, - phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.GREEN) - } - return PlacementGuideResult("Checking lateral...", + // 항상 LATERAL 단계를 거침 + return PlacementGuideResult("Checking left/right balance...", "checkmark.circle", 50, phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.LATERAL) } else if (gradient > 0) { - val intensity = if (gradient > 0.8) "Lower ↓" else "Lower slightly ↓" + val intensity = if (gradient > 0.8) "Lower the sensor down" else "Lower the sensor slightly" return PlacementGuideResult(intensity, "arrow.down", (20 + centerDetected * 5).coerceAtMost(35), phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) } else { - val intensity = if (gradient < -0.8) "Raise ↑" else "Raise slightly ↑" + val intensity = if (gradient < -0.8) "Raise the sensor up" else "Raise the sensor slightly" return PlacementGuideResult(intensity, "arrow.up", (20 + centerDetected * 5).coerceAtMost(35), phase = PlacementPhase.VERTICAL, nextPhase = PlacementPhase.VERTICAL) @@ -1507,10 +1542,10 @@ private fun computePlacementGuide( adjustHint = "Move right →"; adjustIcon = "arrow.right" } gradient > 0.3 -> { - adjustHint = "Lower ↓"; adjustIcon = "arrow.down" + adjustHint = "Lower the sensor down"; adjustIcon = "arrow.down" } gradient < -0.3 -> { - adjustHint = "Raise ↑"; adjustIcon = "arrow.up" + adjustHint = "Raise the sensor up"; adjustIcon = "arrow.up" } else -> { // 방향 애매 + CV 미달 → ch별 len 분석으로 방향 제시 @@ -1521,10 +1556,10 @@ private fun computePlacementGuide( // 최소 채널이 최대의 60% 미만 → 약한 쪽으로 안내 if (minCh <= 1) { // ch0/ch1 약함 → 위로 - adjustHint = "Raise slightly ↑"; adjustIcon = "arrow.up" + adjustHint = "Raise the sensor slightly"; adjustIcon = "arrow.up" } else { // ch2/ch3 약함 → 아래로 - adjustHint = "Lower slightly ↓"; adjustIcon = "arrow.down" + adjustHint = "Lower the sensor slightly"; adjustIcon = "arrow.down" } } else { adjustHint = "Fine-tune position"; adjustIcon = "checkmark.circle"