feat: 10건 UI 피드백 + Auto fail 팝업 + 텍스트 정리

#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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 17:21:11 +09:00
parent 8f2eb15af3
commit b97d3d8052
2 changed files with 105 additions and 57 deletions
@@ -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<Double>() }
val measureDoneSignal = remember { mutableStateOf(kotlinx.coroutines.CompletableDeferred<Unit>()) }
@@ -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
@@ -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<List<PiezoChannelData>>(emptyList()) }
var lastWallResults by remember { mutableStateOf<List<Pair<Int, Int>?>>(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"