From a7133a13ae9cfece979787ff38e33e3056baa5c3 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Sat, 9 May 2026 20:20:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20UI=20=EB=8C=80=EB=9F=89=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20+=20Placement=20=EC=95=88=EC=A0=95=EC=84=B1=20+=20B?= =?UTF-8?q?V=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [Placement] - Start 버튼 필수 (자동 스캔 안 함) - 화면 진입 시 전체 상태 리셋 (Step 2 직행 방지) - Step 1 완료 조건: ch0~ch3 4개 전부 감지 필수 - Step 2 전환 시 "Checking left/right balance..." 3스캔 보호 - Step 전환 힌트 강제 유지 (phaseHoldHint) - GREEN hold 7초 + 3회 연속 실패 후 이탈 - GREEN 이탈 시 스캔 중지 + Start 재시작 대기 - Detach 시 스캔 중지 + Start 버튼 표시 - 힌트 점 순환 (Raise.. / Raise... 업데이트 표시) - 텍스트: "Raise the sensor up" 등 자연어 - 초기/재시작 시 화살표 제거 - Step 2 스킵 제거 (항상 3단계 순서) - GREEN에서 ch4/ch5 개별 탈락 시 방향 안내 - gradient dead zone에 4채널 필수 [도넛차트] - Max Volume 5초 간격 갱신 - Current Measurement / Fill 카드 (Fill = 65% + 325/500 mL) - Auto 윈도우 < 5일 때 Current Measurement "—" - Spot 완료 시 displayMaxVolumeMl 즉시 갱신 - ml → mL 통일 - Sensor Detached 상단 문구 제거 - 카테터 기본값 15개 [기능] - Auto 2ch 미수신 5회 연속 → Realign 팝업 - Spot 간격 800ms→100ms (throttle이 보장) - Auto Stop 버튼 깜빡임 해결 - Plot fallback 제거 (allWalls null이면 피크 안 보임) - Pairing 미완료 시 다음 화면 차단 - 설정 패널 Save/Cancel 버튼 - 뒤로가기 앱 종료 확인 [코드 품질] - Thread → coroutine (measureScope + Dispatchers.Default) - mutableStateOf 메인 스레드 보장 - measureDoneSignal var→val mutableStateOf - @Volatile (BleManager, GreenZoneConstants, PiezoHW) - maa throttle deadlock 방지 (Boolean 반환) - channelLogs allWalls 기준 통일 README v4 업데이트 Co-Authored-By: Claude Opus 4.6 (1M context) --- VesiScan_Android_Pipeline_Summary.md | 2 +- .../services/UserStorage.kt | 2 +- .../views/monitoring/PiezoMonitoringView.kt | 22 +++-- .../ui/views/monitoring/PlacementGuideView.kt | 80 ++++++++++++++----- 4 files changed, 75 insertions(+), 31 deletions(-) diff --git a/VesiScan_Android_Pipeline_Summary.md b/VesiScan_Android_Pipeline_Summary.md index 41fd4c4..761d868 100644 --- a/VesiScan_Android_Pipeline_Summary.md +++ b/VesiScan_Android_Pipeline_Summary.md @@ -2,7 +2,7 @@ ## 작성일: 2026-04-23 ## 작성자: dwjang -## 마지막 업데이트: 2026-05-07 (v3) +## 마지막 업데이트: 2026-05-09 (v4) — VBTAND0101 --- diff --git a/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt b/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt index 47e65df..f98a7be 100644 --- a/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt +++ b/app/src/main/java/com/example/medilightv2android/services/UserStorage.kt @@ -74,7 +74,7 @@ class UserStorage private constructor(context: Context) { set(value) { prefs.edit().putInt("bladder_level", value).apply() } var catheterCount: Int - get() = prefs.getInt("catheter_count", 0) + get() = prefs.getInt("catheter_count", 15) set(value) { prefs.edit().putInt("catheter_count", value).apply() } var hasCompletedOnboarding: Boolean 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 83fa14b..bced260 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 @@ -529,7 +529,7 @@ fun PiezoMonitoringView(appState: AppState) { Text(stringResource(R.string.voiding_record), fontWeight = FontWeight.Bold, fontSize = 18.sp) Spacer(modifier = Modifier.height(4.dp)) Text( - "${appState.estimatedVolume} ml", + "${appState.estimatedVolume} mL", fontSize = 28.sp, fontWeight = FontWeight.Bold, color = MlPrimary ) Text("추정 용량", fontSize = 12.sp, color = MlSecondaryText) @@ -611,7 +611,7 @@ fun PiezoMonitoringView(appState: AppState) { Icon(Icons.Default.FlashOn, contentDescription = null, tint = Color(0xFF4CAF50)) Spacer(modifier = Modifier.width(12.dp)) Text("Quick Save", fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) - Text("$quickVol ml", color = MlSecondaryText, fontSize = 14.sp) + Text("$quickVol mL", color = MlSecondaryText, fontSize = 14.sp) Spacer(modifier = Modifier.width(4.dp)) Icon(Icons.Default.ChevronRight, contentDescription = null, tint = MlSecondaryText) } @@ -913,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 (displayMaxVolumeMl > 0) "${"%.0f".format(displayMaxVolumeMl)} ml" else "${appState.estimatedVolume} ml", + if (displayMaxVolumeMl > 0) "${"%.0f".format(displayMaxVolumeMl)} mL" else "${appState.estimatedVolume} mL", fontSize = volFontSize, fontWeight = FontWeight.Bold, color = urgency.accentColor, @@ -927,9 +927,14 @@ 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)) + // Auto 윈도우 < 5일 때는 표시 안 함 + val showMeasurement = if (isAutoMeasuring) autoVolumeWindow.size >= 5 && lastVolumeMl > 0 else lastVolumeMl > 0 + val currentMeasurement = if (showMeasurement) "%.0f".format(lastVolumeMl) else "—" + InfoCard("Current Measurement", currentMeasurement, "mL", urgency.accentColor, Modifier.weight(1f)) + val maxMeasured = if (displayMaxVolumeMl > 0) "%.0f".format(displayMaxVolumeMl) else "—" + val bladderMax = "${appState.piezoSettings.maxVolume}" + val fillMain = if (fillPct > 0) "${"%.0f".format(fillPct)}%" else "—" + InfoCard("$maxMeasured mL / $bladderMax mL", fillMain, "", urgency.accentColor, Modifier.weight(1f)) } // Control buttons: Void/Catheterize / Auto / Spot @@ -1008,6 +1013,7 @@ fun PiezoMonitoringView(appState: AppState) { val result = trimmedMean(spotVolumes.toList()) lastVolumeMl = result maxVolumeMl = maxOf(maxVolumeMl, result) + displayMaxVolumeMl = maxVolumeMl // Spot은 즉시 갱신 Log.d("PiezoMonitor", "SPOT trimmedMean=${"%.1f".format(result)}ml from ${spotVolumes.map { "%.0f".format(it) }}") val maxVol = appState.piezoSettings.maxVolume.toDouble() val pct = if (maxVol > 0) (result / maxVol).coerceAtMost(1.0) else 0.0 @@ -1469,8 +1475,8 @@ private fun ChannelPanel( // 실제 BV 계산에 사용된 allWalls 값으로 표시 val wall = allWalls.getOrNull(chData.channel) - val antIdx = wall?.first ?: chAnalysis.result?.ant - val postIdx = wall?.second ?: chAnalysis.result?.post + val antIdx = wall?.first + val postIdx = wall?.second val wallLen = if (wall != null) wall.second - wall.first else null Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { 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 d978b01..265a2bb 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 @@ -62,6 +62,9 @@ fun PlacementGuideView(appState: AppState) { var isContinuousMode by remember { mutableStateOf(false) } // Start 버튼으로 시작 var waitingForStart by remember { mutableStateOf(true) } // 초기 안내 상태 var startPressedAtMs by remember { mutableStateOf(0L) } + var lastPhaseChangeMs by remember { mutableStateOf(0L) } + var phaseHoldScans by remember { mutableIntStateOf(0) } + var phaseHoldHint by remember { mutableStateOf("") } // 강제 힌트 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 == @@ -103,18 +106,29 @@ fun PlacementGuideView(appState: AppState) { var sweepLastDetected by remember { mutableIntStateOf(0) } var sweepEverSawAll by remember { mutableStateOf(false) } - // 화면 진입 시 상태 리셋 + // 화면 진입 시 전체 상태 리셋 LaunchedEffect(Unit) { + isContinuousMode = false + waitingForStart = true placementPhase = PlacementPhase.VERTICAL isLocked = false + greenLockedAtMs = 0L + greenFailCount = 0 scanCount = 0 placementScore = 0 + debounceCount = 0 + lastHintChangeMs = 0L + lastGuidePhase = PlacementPhase.VERTICAL + phaseHoldScans = 0 directionHint = "Place the sensor above the pubic bone, then press Start" directionIcon = "" + lastCv = Double.NaN 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 + maxCenterSeen = 0; stableAtMax = 0; boundaryHit = false sweepLastDetected = 0; sweepEverSawAll = false } @@ -348,7 +362,7 @@ fun PlacementGuideView(appState: AppState) { // 센서 기기 이미지 (방광 타원 중앙에 정렬) // 타원 중심 = (navelY+pubisY)/2 ≈ 높이의 68%. Alignment.Center = 50%. 차이 ≈ 18% val boxHeightDp = with(androidx.compose.ui.platform.LocalDensity.current) { - (this@Box).let { 100.dp } // fallback + (this@Box).let { 110.dp } // fallback } androidx.compose.foundation.Image( painter = androidx.compose.ui.res.painterResource(com.example.medilightv2android.R.drawable.sensor_device), @@ -368,8 +382,16 @@ fun PlacementGuideView(appState: AppState) { val hintGreen = isGreenZone val hintBg = if (hintGreen) Color(0xFF4CAF50).copy(alpha = 0.12f) else Color(0xFFFFF3E0) val hintFg = if (hintGreen) Color(0xFF4CAF50) else Color(0xFFFF9800) + // 방향 힌트에 점 순환 (. / .. / ...) — 업데이트 중임을 시각적으로 표시 + val dots = ".".repeat((scanCount % 3) + 1) val displayHint = buildString { - append(directionHint.ifEmpty { " " }) + val hint = directionHint.ifEmpty { " " } + append(hint) + // 방향 안내 중일 때만 점 추가 (Optimal, Place sensor 등은 제외) + if (hint.startsWith("Raise") || hint.startsWith("Lower") || hint.startsWith("Move") || + hint.startsWith("Fine-tune") || hint.startsWith("Checking")) { + append(dots) + } if (appState.isDevMode && !lastCv.isNaN()) { append(" (CV=${"%.3f".format(lastCv)})") } @@ -638,18 +660,32 @@ fun PlacementGuideView(appState: AppState) { wasGreen != isNowGreen } val phaseChanged = guideResult.nextPhase != placementPhase + // phase 전환 후 3스캔 보호 (카운터 기반, 타이밍 무관) + val phaseProtected = phaseHoldScans > 0 + if (phaseProtected) phaseHoldScans-- // 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)) { + val hintUpdate = justStarted && !phaseChanged && !phaseProtected + if (hintUpdate || greenChanged || (phaseChanged && !greenHeld && debounceCount >= 2 && elapsed >= 3000) || (!phaseChanged && !phaseProtected && !greenHeld && debounceCount >= 2 && elapsed >= 1500)) { directionHint = guideResult.hint directionIcon = guideResult.icon placementScore = guideResult.score + if (phaseChanged) { + lastPhaseChangeMs = now + phaseHoldScans = 3 + phaseHoldHint = guideResult.hint // 전환 힌트 저장 + } placementPhase = guideResult.nextPhase lastHintChangeMs = now if (!guideResult.cv.isNaN()) lastCv = guideResult.cv } + // phase 보호 중이면 전환 힌트 강제 유지 (무조건 덮어쓰기) + if (phaseProtected && phaseHoldHint.isNotEmpty()) { + directionHint = phaseHoldHint + directionIcon = "checkmark.circle" + } + // GREEN hold 중이면 힌트 강제 유지 if (greenHeld) { directionHint = "Optimal position!" @@ -748,6 +784,7 @@ fun PlacementGuideView(appState: AppState) { lastHintChangeMs = 0L scanCount = 0 startPressedAtMs = System.currentTimeMillis() + phaseHoldScans = 0 placementPhase = PlacementPhase.VERTICAL isLocked = false greenLockedAtMs = 0L @@ -1332,25 +1369,16 @@ private fun computePlacementGuide( "VERTICAL gradient=%.2f (center=%.2f) variation=%.2f detect=$centerDetected lens=$centerLens" .format(gradient, weightedCenter, lenVariation)) - if (kotlin.math.abs(gradient) < gradientThr && lenVariation < 0.35) { + if (centerDetected == 4 && kotlin.math.abs(gradient) < gradientThr && lenVariation < 0.35) { // 상하 OK → 좌우 확인 val l4 = urineLens.getOrElse(4) { 0 } val l5 = urineLens.getOrElse(5) { 0 } val ch4ok = detected.getOrElse(4) { false } val ch5ok = detected.getOrElse(5) { false } - // 항상 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 left/right balance..." - } - val lrIcon = when { - l4 > 0 && l5 > 0 && l4 > l5 -> "arrow.left" - l4 > 0 && l5 > 0 && l5 > l4 -> "arrow.right" - else -> "checkmark.circle" - } + // 항상 LATERAL 단계를 거침 — 전환 시 항상 Checking 표시 + val lrHint = "Checking left/right balance..." + val lrIcon = "checkmark.circle" val score = (40 + ((1.0 - kotlin.math.abs(gradient) / gradientThr) * 20).toInt()) .coerceIn(40, 60) return PlacementGuideResult(lrHint, lrIcon, score, @@ -1359,7 +1387,7 @@ private fun computePlacementGuide( // gradient 기반 방향 안내 (dead zone: |gradient|<0.15 → LATERAL로) // ch0=위, ch3=아래. gradient>0 = ch3쪽 길다 = 방광 중심이 아래 = 내려야 - if (kotlin.math.abs(gradient) < 0.15) { + if (centerDetected == 4 && kotlin.math.abs(gradient) < 0.15) { // gradient 거의 0 — LATERAL로 진행 (위아래 깜빡임 방지) val l4 = urineLens.getOrElse(4) { 0 } val l5 = urineLens.getOrElse(5) { 0 } @@ -1532,13 +1560,23 @@ private fun computePlacementGuide( // 방향이 틀리면 해당 방향으로 안내, 방향 OK면 GREEN에 머물며 재시도 val adjustHint: String; val adjustIcon: String + val ch4det = detected.getOrElse(4) { false } + val ch5det = detected.getOrElse(5) { false } when { + // ch4/ch5 중 하나만 빠짐 → 반대 방향 안내 + !lrPass && !ch4det && ch5det -> { + adjustHint = "Move left ←"; adjustIcon = "arrow.left" + } + !lrPass && ch4det && !ch5det -> { + adjustHint = "Move right →"; adjustIcon = "arrow.right" + } + !lrPass && !ch4det && !ch5det -> { + adjustHint = "Adjust left or right"; adjustIcon = "arrow.right" + } !lrPass && lrDev > DEFAULT_LR_DEV_THR -> { - // d4 > d5 → 방광 왼쪽 → 왼쪽으로 adjustHint = "Move left ←"; adjustIcon = "arrow.left" } !lrPass && lrDev < -DEFAULT_LR_DEV_THR -> { - // d5 > d4 → 방광 오른쪽 → 오른쪽으로 adjustHint = "Move right →"; adjustIcon = "arrow.right" } gradient > 0.3 -> {