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:
+127
-9
@@ -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")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+104
-9
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user