feat: Placement Start flow + Auto fail redirect + Spot 800ms

#1 Placement: 초기 "Place sensor, then press Start" 안내
   Start Alignment 버튼으로 스캔 시작 (자동 시작 안 함)
#2 GREEN 이탈 시 스캔 중지 + "Position lost, press Start" 재시작 대기
#3 Auto: 3회 연속 실패(채널 미수신/BV 불가) → Auto 중지 +
   "Sensor Alignment Required" 다이얼로그 → Go to Alignment
#4 Spot 간격 1500ms → 800ms (총 ~5초로 단축)

기타:
- Auto Stop 깜빡거림 해결 (enabled에서 isMeasuring 제거)
- 미부착 시 상단 배지 "Sensor detached" 빨간 표시
- 카테터 수 상단 바 표시 (탭→추가 팝업)
- Void 버튼 → 즉시 기록+감소 (팝업 없이)
- ch4/ch5 하나라도 빠지면 GREEN 해제 (lrPass 기본 false)
- GREEN hold 5초 + 이탈 시 Start over
- Placement Post Max 슬라이더 추가 (개발자 설정)
- 설정 패널 Save/Cancel 버튼

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 14:46:04 +09:00
parent 38ab74369e
commit 8f2eb15af3
2 changed files with 231 additions and 18 deletions
@@ -107,6 +107,8 @@ fun PiezoMonitoringView(appState: AppState) {
}
val measureScope = rememberCoroutineScope()
var autoFailCount by remember { mutableIntStateOf(0) }
var showRealignPrompt by remember { mutableStateOf(false) }
fun measure() {
if (isMeasuring) {
@@ -327,12 +329,26 @@ fun PiezoMonitoringView(appState: AppState) {
}
}
// Auto 연속 실패 → UI에서 처리
val autoFailed = isAutoMeasuring && (finalRawVol == null || finalRawVol <= 0 || !allChannelsReceived)
val autoSucceeded = isAutoMeasuring && finalRawVol != null && finalRawVol > 0 && allChannelsReceived
if (lastVolumeMl > 0 && !isSpotInProgress) {
val maxVol = appState.piezoSettings.maxVolume.toDouble()
val pct = if (maxVol > 0) (lastVolumeMl / maxVol).coerceAtMost(1.0) else 0.0
val level = (pct * BladderLevel.MAX_LEVEL).toInt().coerceIn(1, BladderLevel.MAX_LEVEL)
appState.bladderLevel = appState.bladderLevel.copy(currentLevel = level)
}
// Auto 연속 실패 카운트
if (autoSucceeded) autoFailCount = 0
if (autoFailed) {
autoFailCount++
if (autoFailCount >= 3) {
isAutoMeasuring = false
autoVolumeWindow.clear()
showRealignPrompt = true
}
}
isMeasuring = false
measureRetryCount = 0
try { measureDoneSignal.value.complete(Unit) } catch (_: Exception) {}
@@ -743,6 +759,51 @@ fun PiezoMonitoringView(appState: AppState) {
}
}
Spacer(modifier = Modifier.width(8.dp))
// Catheter count (탭하면 추가 팝업)
var showAddCatheter by remember { mutableStateOf(false) }
val cathCount = appState.catheterCount.intValue
Surface(
onClick = { showAddCatheter = true },
shape = RoundedCornerShape(12.dp),
color = if (cathCount > 0) MlPrimary.copy(alpha = 0.1f) else Color.Gray.copy(alpha = 0.1f)
) {
Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.LocalHospital, "Catheter", tint = MlPrimary, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("$cathCount", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
}
if (showAddCatheter) {
var addCount by remember { mutableStateOf("1") }
AlertDialog(
onDismissRequest = { showAddCatheter = false },
title = { Text("Add Catheters") },
text = {
OutlinedTextField(
value = addCount,
onValueChange = { addCount = it.filter { c -> c.isDigit() } },
label = { Text("Count") },
singleLine = true
)
},
confirmButton = {
TextButton(onClick = {
val n = addCount.toIntOrNull() ?: 0
if (n > 0) appState.addCatheterCount(n)
showAddCatheter = false
}) { Text("Add") }
},
dismissButton = {
TextButton(onClick = { showAddCatheter = false }) { Text("Cancel") }
}
)
}
Spacer(modifier = Modifier.width(4.dp))
// Settings button
@@ -782,15 +843,17 @@ fun PiezoMonitoringView(appState: AppState) {
Arrangement.spacedBy(if (isCompact) 32.dp else 40.dp)
) {
// Status badge
// Status badge (미부착 시 경고)
val statusText = if (wasDetached) "Sensor detached" else urgency.message
val statusColor = if (wasDetached) Color.Red else urgency.accentColor
Text(
text = urgency.message,
text = statusText,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = urgency.accentColor,
color = statusColor,
modifier = Modifier
.clip(RoundedCornerShape(20.dp))
.background(urgency.accentColor.copy(alpha = 0.15f))
.background(statusColor.copy(alpha = 0.15f))
.padding(horizontal = 20.dp, vertical = 8.dp)
)
@@ -859,9 +922,21 @@ fun PiezoMonitoringView(appState: AppState) {
// Control buttons: Void/Catheterize / Auto / Spot
val btnHeight = if (appState.isDevMode) 56.dp else 62.dp
Row(horizontalArrangement = Arrangement.spacedBy(if (appState.isDevMode) 8.dp else 10.dp)) {
// Void / Catheterize (측정 중 봉인)
// Void / Catheterize — 즉시 카테터 1개 감소 + 기록
Button(
onClick = { showCatheterizeSheet = true },
onClick = {
val quickVol = if (lastVolumeMl > 0) lastVolumeMl.toInt() else appState.estimatedVolume
com.example.medilightv2android.measure.VoidingRecordStore.add(
context, com.example.medilightv2android.measure.VoidingRecord(
volume = quickVol, method = "catheterize"
)
)
appState.resetLevel()
lastVolumeMl = 0.0
maxVolumeMl = 0.0
resetMeasurement()
android.widget.Toast.makeText(context, "Voiding recorded", android.widget.Toast.LENGTH_SHORT).show()
},
enabled = !isSpotInProgress && !isAutoMeasuring,
modifier = Modifier.weight(1.3f).height(btnHeight),
shape = RoundedCornerShape(14.dp),
@@ -881,7 +956,7 @@ fun PiezoMonitoringView(appState: AppState) {
onClick = {
if (isAutoMeasuring) stopAutoMeasure() else startAutoMeasure()
},
enabled = !isSpotInProgress && !isMeasuring,
enabled = !isSpotInProgress,
modifier = Modifier.weight(1f).height(btnHeight),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
@@ -913,7 +988,7 @@ fun PiezoMonitoringView(appState: AppState) {
attempts++
measure()
while (isMeasuring) { kotlinx.coroutines.delay(100) }
kotlinx.coroutines.delay(1500)
kotlinx.coroutines.delay(800)
}
Log.d("PiezoMonitor", "SPOT attempts=$attempts results=${spotVolumes.size}")
if (spotVolumes.size >= 3) {
@@ -995,7 +1070,6 @@ fun PiezoMonitoringView(appState: AppState) {
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
.clickable { showSettings = false }
)
Card(
shape = RoundedCornerShape(16.dp),
@@ -1207,10 +1281,54 @@ fun PiezoMonitoringView(appState: AppState) {
dismissButton = { TextButton(onClick = { showDisconnectConfirm = false }) { Text("Cancel") } }
)
}
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
// Save / Cancel
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = { showSettings = false }) {
Text("Cancel", color = MlSecondaryText)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { showSettings = false },
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) {
Text("Save", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
} // end Box
// Realign prompt
if (showRealignPrompt) {
AlertDialog(
onDismissRequest = { showRealignPrompt = false },
title = { Text("Sensor Alignment Required") },
text = { Text("Measurement failed multiple times. Please re-align the sensor.") },
confirmButton = {
Button(onClick = {
showRealignPrompt = false
autoFailCount = 0
appState.currentScreen = com.example.medilightv2android.AppScreen.PLACEMENT_GUIDE
}) { Text("Go to Alignment") }
},
dismissButton = {
TextButton(onClick = { showRealignPrompt = false; autoFailCount = 0 }) {
Text("Dismiss")
}
}
)
}
}
}
@@ -59,7 +59,8 @@ fun PlacementGuideView(appState: AppState) {
val analyzer = remember { PiezoEchoAnalyzer.shared }
var isScanning by remember { mutableStateOf(false) }
var isContinuousMode by remember { mutableStateOf(true) }
var isContinuousMode by remember { mutableStateOf(false) } // Start 버튼으로 시작
var waitingForStart by remember { mutableStateOf(true) } // 초기 안내 상태
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 ==
@@ -73,6 +74,8 @@ fun PlacementGuideView(appState: AppState) {
var lastCv by remember { mutableStateOf(Double.NaN) }
var scanCount by remember { mutableIntStateOf(0) }
var isLocked by remember { mutableStateOf(false) }
var greenLockedAtMs by remember { mutableStateOf(0L) } // GREEN 진입 시각
val greenHoldMs = 4000L // GREEN 후 최소 4초 유지
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) }
@@ -104,8 +107,7 @@ fun PlacementGuideView(appState: AppState) {
isLocked = false
scanCount = 0
placementScore = 0
directionHint = if (isSweepMode) "Place sensor near navel, slide down ↓"
else "Place the sensor just above the pubic bone"
directionHint = "Place the sensor above the pubic bone, then press Start"
directionIcon = ""
for (i in 0 until 6) { channelDetected[i] = false; channelUrineLen[i] = 0 }
rTracker.reset()
@@ -352,7 +354,7 @@ fun PlacementGuideView(appState: AppState) {
modifier = Modifier
.size(width = 192.dp, height = 120.dp)
.align(Alignment.Center)
.offset(x = 0.dp, y = 65.dp), // 타원 중심 방향
.offset(x = 0.dp, y = 110.dp),
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
alpha = 0.95f
)
@@ -547,6 +549,18 @@ fun PlacementGuideView(appState: AppState) {
}
}
// postMaxIdx 필터: 후벽이 이 샘플 이상이면 바닥반사로 판단 → 제거
val postMax = com.example.medilightv2android.managers.GreenZoneConstants.postMaxIdx
for (ch in 0..5) {
val w = allWalls[ch]
if (w != null && w.second > postMax) {
Log.w("PlacementGuide", "CH$ch post=${w.second} > postMaxIdx=$postMax → removed")
allWalls[ch] = null
detected[ch] = false
urineLens[ch] = 0
}
}
for (i in 0 until 6) {
channelDetected[i] = detected[i]
channelUrineLen[i] = urineLens[i]
@@ -620,13 +634,39 @@ fun PlacementGuideView(appState: AppState) {
if (!guideResult.cv.isNaN()) lastCv = guideResult.cv
}
if (guideResult.isPass) {
// GREEN hold: 한번 optimal 진입 후 5초간 유지 (미세 변화 무시)
val greenHeld = isLocked && (now - greenLockedAtMs < greenHoldMs)
if (guideResult.isPass || greenHeld) {
if (!isLocked) greenLockedAtMs = now // 최초 진입 시각 기록
isLocked = true
if (lastLedMode != 6) { bleManager.sendLedMode(6); lastLedMode = 6 }
// GREEN hold 중이면 힌트도 optimal 유지
if (greenHeld && !guideResult.isPass) {
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) {
directionHint = "Position lost. Place sensor above pubic bone, then press Start"
directionIcon = "arrow.up"
placementScore = 10
placementPhase = PlacementPhase.VERTICAL
lastHintChangeMs = now
isContinuousMode = false
waitingForStart = true
scanCount = 0
maxCenterSeen = 0; stableAtMax = 0; boundaryHit = false
sweepLastDetected = 0; sweepEverSawAll = false
rTracker.reset()
}
bleManager.debugLogger.info("PLACEMENT_${guideResult.phase.name} score=${guideResult.score} otsu=${"%.0f".format(analyzer.lastOtsuThreshold)} hint=${guideResult.hint}")
}
}
@@ -670,8 +710,25 @@ fun PlacementGuideView(appState: AppState) {
}
}
// Alignment Complete button
Button(
// Start button (초기 / 재시작 대기 시)
if (waitingForStart) {
Button(
onClick = {
waitingForStart = false
isContinuousMode = true
},
modifier = Modifier
.fillMaxWidth()
.height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFFF9800))
) {
Text("Start Alignment", fontSize = 16.sp, fontWeight = FontWeight.Bold, color = Color.White)
}
}
// Alignment Complete button (스캔 진행 중에만)
if (!waitingForStart) Button(
onClick = {
isContinuousMode = false
appState.placementComplete()
@@ -711,7 +768,6 @@ fun PlacementGuideView(appState: AppState) {
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.3f))
.clickable { showPlacementSettings = false }
)
Card(
shape = RoundedCornerShape(14.dp),
@@ -808,6 +864,45 @@ fun PlacementGuideView(appState: AppState) {
colors = SwitchDefaults.colors(checkedTrackColor = Color(0xFF4CAF50))
)
}
HorizontalDivider()
// Post Max Index (바닥반사 무시 경계)
var postMaxVal by remember { mutableStateOf(com.example.medilightv2android.managers.GreenZoneConstants.postMaxIdx.toFloat()) }
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Post Max", fontSize = 14.sp, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(8.dp))
Text("${postMaxVal.toInt()} / 100", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
}
Slider(
value = postMaxVal,
onValueChange = { postMaxVal = it; com.example.medilightv2android.managers.GreenZoneConstants.postMaxIdx = it.toInt() },
valueRange = 50f..95f,
steps = 8,
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
)
Spacer(modifier = Modifier.height(8.dp))
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
verticalAlignment = Alignment.CenterVertically
) {
TextButton(onClick = { showPlacementSettings = false }) {
Text("Cancel", color = MlSecondaryText)
}
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { showPlacementSettings = false },
shape = RoundedCornerShape(10.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) {
Text("Save", color = Color.White, fontWeight = FontWeight.Bold)
}
}
}
}
}
@@ -1364,7 +1459,7 @@ private fun computePlacementGuide(
// LR deviation
var lrDev = Double.NaN
var lrPass = true
var lrPass = false // ch4+ch5 둘 다 있어야 통과
val w4 = if (4 < walls.size) walls[4] else null
val w5 = if (5 < walls.size) walls[5] else null
if (w4 != null && w5 != null) {