UI: 8건 피드백 반영 + Spot 봉인 + 페어링 체크

[Placement]
#1 센서 위치 타원 중앙 (offset y=65dp)
#5 skin 색 항상 동일 (greenZone 분기 제거)
#6 화살표 센서 밖으로 (sensorHalfH + 20f 간격)
#8 GREEN 체크마크 제거
센서 크기 192x120dp (80%)

[Donut]
#2 상단 버튼 1.5배 (48/56dp)
#3 배터리 아이콘 Canvas 그리기 (충전량 바)
#4 도넛-카드 간격 2배 (32/40dp)
#7 Bladdy 정중앙 (matchParentSize + Arrangement.Center)

[기능]
- Spot 중 Auto/Void 버튼 봉인 (enabled=false)
- BLE 페어링(bonding) 완료 확인 후 다음 화면 (30초 대기)
- R-peak gate Gradient 모드 비활성화
- GREEN 디버그 로그 (cv/lrPass/rPeakOk)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 17:27:34 +09:00
parent 72ccbdf102
commit 38ab74369e
4 changed files with 92 additions and 105 deletions
@@ -73,6 +73,9 @@ class BleManager private constructor(private val context: Context) {
val batteryLevel = mutableStateOf(0)
val bluetoothEnabled = mutableStateOf(bluetoothAdapter?.isEnabled == true)
val isServiceReady = mutableStateOf(false)
val isBonded: Boolean
@android.annotation.SuppressLint("MissingPermission")
get() = bluetoothGatt?.device?.bondState == android.bluetooth.BluetoothDevice.BOND_BONDED
val isReconnecting = mutableStateOf(false)
val connectionError = mutableStateOf<String?>(null)
@@ -94,10 +94,23 @@ fun DeviceScanView(appState: AppState) {
}
}
// 서비스 준비 + 페어링(bonding) 완료 확인 후 다음 화면
LaunchedEffect(isServiceReady) {
if (isServiceReady) {
connectingDeviceAddress = null
appState.deviceConnected()
// 페어링 완료 대기 (최대 30초, 500ms 간격 폴링)
var waited = 0
while (!bleManager.isBonded && waited < 30000) {
kotlinx.coroutines.delay(500)
waited += 500
}
if (bleManager.isBonded) {
appState.deviceConnected()
} else {
// 페어링 거부/타임아웃 → 연결 해제
bleManager.disconnect()
bleManager.connectionError.value = "Pairing required. Please pair the device."
}
}
}
@@ -253,21 +253,6 @@ fun PiezoMonitoringView(appState: AppState) {
}
val rawADC = channels.sortedBy { it.channel }.map { it.buffer }
// Cross-channel urine length outlier 제거
// median의 1.4배 초과 또는 0.6배 미만 채널은 outlier → null 처리
val urineLensAll = (0..5).map { allWalls[it]?.let { w -> w.second - w.first } ?: 0 }
val validLensAll = urineLensAll.filter { it > 0 }
if (validLensAll.size >= 3) {
val medianLen = validLensAll.sorted()[validLensAll.size / 2]
for (ch in 0..5) {
val len = urineLensAll[ch]
if (len > 0 && (len > medianLen * 1.35 || len < medianLen * 0.5)) {
Log.w("PiezoMonitor", "CH$ch urine=$len OUTLIER (median=$medianLen) → removed")
allWalls[ch] = null
}
}
}
val bvMethodSetting = com.example.medilightv2android.managers.GreenZoneConstants.bvMethod
val useV41Bv = method == com.example.medilightv2android.managers.DetectionMethod.METHOD_C
&& bvMethodSetting == com.example.medilightv2android.managers.BvMethod.V41
@@ -722,21 +707,40 @@ fun PiezoMonitoringView(appState: AppState) {
.padding(horizontal = hPad, vertical = if (isCompact) 8.dp else 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
val btnSize = if (isCompact) 36.dp else 44.dp
val iconSize = if (isCompact) 20.dp else 24.dp
val btnSize = if (isCompact) 48.dp else 56.dp
val iconSize = if (isCompact) 28.dp else 32.dp
IconButton(onClick = { appState.currentScreen = AppScreen.PLACEMENT_GUIDE }, modifier = Modifier.size(btnSize)) {
Icon(Icons.Default.MyLocation, "Placement", tint = MlPrimary, modifier = Modifier.size(iconSize))
}
Spacer(modifier = Modifier.weight(1f))
// Battery percentage (초록 dot 제거)
// Battery with icon
if (!isDemoMode) {
val batteryColor = when {
batteryLvl >= 60 -> Color(0xFF4CAF50)
batteryLvl >= 30 -> Color(0xFFFF9800)
else -> Color.Red
}
Text("$batteryLvl%", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = batteryColor)
Row(verticalAlignment = Alignment.CenterVertically) {
// 배터리 바 아이콘 (Canvas로 직접 그리기)
Canvas(modifier = Modifier.size(width = 22.dp, height = 12.dp)) {
val w = size.width; val h = size.height
// 외곽
drawRoundRect(Color.Gray, topLeft = Offset(0f, 0f),
size = Size(w * 0.88f, h), cornerRadius = androidx.compose.ui.geometry.CornerRadius(3f),
style = Stroke(width = 1.5f))
// 양극 돌출
drawRect(Color.Gray, topLeft = Offset(w * 0.88f, h * 0.25f),
size = Size(w * 0.12f, h * 0.5f))
// 충전량
val fillW = w * 0.84f * (batteryLvl / 100f).coerceIn(0f, 1f)
if (fillW > 0) drawRoundRect(batteryColor,
topLeft = Offset(2f, 2f), size = Size(fillW, h - 4f),
cornerRadius = androidx.compose.ui.geometry.CornerRadius(2f))
}
Spacer(modifier = Modifier.width(4.dp))
Text("$batteryLvl%", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = batteryColor)
}
}
Spacer(modifier = Modifier.width(4.dp))
@@ -775,7 +779,7 @@ fun PiezoMonitoringView(appState: AppState) {
verticalArrangement = if (appState.isDevMode)
Arrangement.spacedBy(if (isCompact) 12.dp else 20.dp)
else
Arrangement.spacedBy(if (isCompact) 20.dp else 28.dp)
Arrangement.spacedBy(if (isCompact) 32.dp else 40.dp)
) {
// Status badge
@@ -819,8 +823,12 @@ fun PiezoMonitoringView(appState: AppState) {
},
size = donutSize
)
Column(horizontalAlignment = Alignment.CenterHorizontally) {
// Bladdy 크기: 일반 모드에서 크게
// Bladdy + 볼륨 (정중앙)
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.matchParentSize()
) {
val bladdySize = if (appState.isDevMode) {
if (isCompact) 80.dp else 110.dp
} else {
@@ -828,7 +836,6 @@ fun PiezoMonitoringView(appState: AppState) {
}
BladdyRiveView(urgency = urgency, size = bladdySize)
Spacer(modifier = Modifier.height(4.dp))
// 볼륨 텍스트 — 배경 없이 깔끔하게
val volFontSize = if (appState.isDevMode) 18.sp else 24.sp
Text(
if (maxVolumeMl > 0) "${"%.0f".format(maxVolumeMl)} ml" else "${appState.estimatedVolume} ml",
@@ -852,12 +859,16 @@ 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 (측정 중 봉인)
Button(
onClick = { showCatheterizeSheet = true },
enabled = !isSpotInProgress && !isAutoMeasuring,
modifier = Modifier.weight(1.3f).height(btnHeight),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary.copy(alpha = 0.1f))
colors = ButtonDefaults.buttonColors(
containerColor = MlPrimary.copy(alpha = 0.1f),
disabledContainerColor = Color.Gray.copy(alpha = 0.1f)
)
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Void /", fontSize = 12.sp, fontWeight = FontWeight.SemiBold, color = MlPrimary)
@@ -865,15 +876,17 @@ fun PiezoMonitoringView(appState: AppState) {
}
}
// Auto
// Auto (Spot 진행 중 봉인)
Button(
onClick = {
if (isAutoMeasuring) stopAutoMeasure() else startAutoMeasure()
},
enabled = !isSpotInProgress && !isMeasuring,
modifier = Modifier.weight(1f).height(btnHeight),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(
containerColor = if (isAutoMeasuring) Color.Red else Color(0xFFFF9800)
containerColor = if (isAutoMeasuring) Color.Red else Color(0xFFFF9800),
disabledContainerColor = Color.Gray.copy(alpha = 0.3f)
)
) {
if (isAutoMeasuring) {
@@ -207,7 +207,7 @@ fun PlacementGuideView(appState: AppState) {
contentAlignment = Alignment.Center
) {
// 하복부 실루엣 (Canvas — CKLaw body silhouette)
val bodyAlpha = if (isGreenZone) 0.6f else 0.9f
val bodyAlpha = 0.9f // 항상 동일 (#5)
Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.width
val h = size.height
@@ -302,107 +302,61 @@ fun PlacementGuideView(appState: AppState) {
style = Stroke(width = 1.5f, pathEffect = PathEffect.dashPathEffect(floatArrayOf(6f, 4f)))
)
// ── 방향 화살표 (센서와 안 겹치게) ──
// ── 방향 화살표 (센서/타원 바깥, 충분한 간격) ──
if (!greenZone) {
val arrowColor = Color(0xFFFF5722)
val arrowLen = 70f
val arrowHead = 28f
val sw = 9f
val gap = 14f
// 위쪽 화살표는 나비 위쪽 (센서 안 겹치게)
val upArrowBase = navelY - h * 0.02f
// 센서 영역 밖으로 확실히 벗어나는 간격
val sensorHalfH = h * 0.08f // 센서 이미지 대략 높이의 절반
when {
directionIcon.contains("up") -> {
val ax = cx; val ay = upArrowBase
drawLine(arrowColor, Offset(ax, ay + arrowLen), Offset(ax, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(ax - arrowHead, ay + arrowHead), Offset(ax, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(ax + arrowHead, ay + arrowHead), Offset(ax, ay), strokeWidth = sw)
// 타원 위 + 센서 위 확실히 벗어남
val ay = bladderCy - bladderRy - sensorHalfH - 20f
drawLine(arrowColor, Offset(cx, ay + arrowLen), Offset(cx, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(cx - arrowHead, ay + arrowHead), Offset(cx, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(cx + arrowHead, ay + arrowHead), Offset(cx, ay), strokeWidth = sw)
}
directionIcon.contains("down") -> {
val ax = cx; val ay = bladderCy + bladderRy + gap
drawLine(arrowColor, Offset(ax, ay - arrowLen), Offset(ax, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(ax - arrowHead, ay - arrowHead), Offset(ax, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(ax + arrowHead, ay - arrowHead), Offset(ax, ay), strokeWidth = sw)
val ay = bladderCy + bladderRy + sensorHalfH + 20f
drawLine(arrowColor, Offset(cx, ay - arrowLen), Offset(cx, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(cx - arrowHead, ay - arrowHead), Offset(cx, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(cx + arrowHead, ay - arrowHead), Offset(cx, ay), strokeWidth = sw)
}
directionIcon.contains("left") -> {
val ax = cx - bladderRx - gap; val ay = bladderCy
drawLine(arrowColor, Offset(ax + arrowLen, ay), Offset(ax, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(ax + arrowHead, ay - arrowHead), Offset(ax, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(ax + arrowHead, ay + arrowHead), Offset(ax, ay), strokeWidth = sw)
val ax = cx - bladderRx - 30f
drawLine(arrowColor, Offset(ax + arrowLen, bladderCy), Offset(ax, bladderCy), strokeWidth = sw)
drawLine(arrowColor, Offset(ax + arrowHead, bladderCy - arrowHead), Offset(ax, bladderCy), strokeWidth = sw)
drawLine(arrowColor, Offset(ax + arrowHead, bladderCy + arrowHead), Offset(ax, bladderCy), strokeWidth = sw)
}
directionIcon.contains("right") -> {
val ax = cx + bladderRx + gap; val ay = bladderCy
drawLine(arrowColor, Offset(ax - arrowLen, ay), Offset(ax, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(ax - arrowHead, ay - arrowHead), Offset(ax, ay), strokeWidth = sw)
drawLine(arrowColor, Offset(ax - arrowHead, ay + arrowHead), Offset(ax, ay), strokeWidth = sw)
val ax = cx + bladderRx + 30f
drawLine(arrowColor, Offset(ax - arrowLen, bladderCy), Offset(ax, bladderCy), strokeWidth = sw)
drawLine(arrowColor, Offset(ax - arrowHead, bladderCy - arrowHead), Offset(ax, bladderCy), strokeWidth = sw)
drawLine(arrowColor, Offset(ax - arrowHead, bladderCy + arrowHead), Offset(ax, bladderCy), strokeWidth = sw)
}
}
}
// ── Green 체크마크 ──
if (greenZone) {
val checkCy = bladderCy - bladderRy - 40f
drawCircle(Color(0xFF4CAF50).copy(alpha = 0.2f), radius = 35f, center = Offset(cx, checkCy))
val checkPath = Path().apply {
moveTo(cx - 15f, checkCy)
lineTo(cx - 5f, checkCy + 10f)
lineTo(cx + 15f, checkCy - 12f)
}
drawPath(checkPath, Color(0xFF4CAF50), style = Stroke(width = 4f))
}
}
// 센서 기기 이미지 (방광 위에 오버레이)
// 센서 기기 이미지 (방광 타원 중앙에 정렬)
// 타원 중심 = (navelY+pubisY)/2 ≈ 높이의 68%. Alignment.Center = 50%. 차이 ≈ 18%
val boxHeightDp = with(androidx.compose.ui.platform.LocalDensity.current) {
(this@Box).let { 100.dp } // fallback
}
androidx.compose.foundation.Image(
painter = androidx.compose.ui.res.painterResource(com.example.medilightv2android.R.drawable.sensor_device),
contentDescription = "Sensor",
modifier = Modifier
.size(width = 240.dp, height = 150.dp)
.size(width = 192.dp, height = 120.dp)
.align(Alignment.Center)
.offset(x = 0.dp, y = 95.dp),
.offset(x = 0.dp, y = 65.dp), // 타원 중심 방향
contentScale = androidx.compose.ui.layout.ContentScale.Fit,
alpha = if (isGreenZone) 0.7f else 0.95f
alpha = 0.95f
)
// Score + 감지수 + R-fit (개발자 모드에서만)
if (appState.isDevMode) {
Column(
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp),
horizontalAlignment = Alignment.End
) {
Text("$detectedCount/6", fontSize = 16.sp, fontWeight = FontWeight.Bold,
color = if (detectedCount >= 4) Color(0xFF4CAF50) else Color(0xFFFF9800))
if (placementScore > 0) {
Text("$placementScore%", fontSize = 13.sp, fontWeight = FontWeight.Bold,
color = when {
placementScore >= 80 -> Color(0xFF4CAF50)
placementScore >= 50 -> Color(0xFFFF9800)
else -> Color.Red
})
}
if (isSweepMode && currentRMm != null) {
val rColor = when {
bestRMm > 0 && (bestRMm - currentRMm!!) < 1.5 -> Color(0xFF4CAF50)
currentRMm!! > (bestRMm * 0.8) -> Color(0xFF7C3AED)
else -> Color(0xFFFF9800)
}
Text("R=${"%.0f".format(currentRMm)}mm", fontSize = 12.sp,
fontWeight = FontWeight.Bold, color = rColor)
if (bestRMm > 0) {
Text("best=${"%.0f".format(bestRMm)}", fontSize = 10.sp,
color = Color(0xFF4CAF50).copy(alpha = 0.7f))
}
sagittalArgMaxCh?.let { arg ->
Text("argmax=CH$arg", fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = if (arg == 0) Color(0xFF4CAF50) else Color(0xFFFF9800))
}
}
}
}
}
@@ -1423,10 +1377,14 @@ private fun computePlacementGuide(
}
}
// R-peak gate (SWEEP 모드): bestR 근처에 있어야 GREEN 통과
val rPeakOk = currentRMm == null || bestRMm <= 0.0 ||
// R-peak gate (SWEEP 모드에서만 적용)
val isSweep = com.example.medilightv2android.managers.GreenZoneConstants.placementGuideMode ==
com.example.medilightv2android.managers.PlacementGuideMode.SWEEP
val rPeakOk = !isSweep || currentRMm == null || bestRMm <= 0.0 ||
(bestRMm - currentRMm) <= 1.5
val isPass = cv <= cvThr && lrPass && rPeakOk
android.util.Log.d("PlacementGuide",
"GREEN check: cv=${"%.3f".format(cv)} cvThr=${"%.3f".format(cvThr)} cvOk=${cv <= cvThr} lrPass=$lrPass lrDev=${"%.3f".format(lrDev)} rPeakOk=$rPeakOk → isPass=$isPass")
if (isPass) {
val score = ((1.0 - cv / cvThr).coerceIn(0.0, 1.0) * 100).toInt().coerceAtLeast(70)