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 a806b3a..f17cc9e 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 @@ -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") + } + } + ) + } } } 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 06f0902..4dd9810 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 @@ -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>(emptyList()) } var lastWallResults by remember { mutableStateOf?>>(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) {