feat: UI 대량 개선 + Placement 안정성 + BV 수정

[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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 20:20:34 +09:00
parent b97d3d8052
commit a7133a13ae
4 changed files with 75 additions and 31 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
## 작성일: 2026-04-23
## 작성자: dwjang
## 마지막 업데이트: 2026-05-07 (v3)
## 마지막 업데이트: 2026-05-09 (v4) — VBTAND0101
---
@@ -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
@@ -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)) {
@@ -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 -> {