From 38ab74369e8837c40af691626788928378c70257 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Fri, 8 May 2026 17:27:34 +0900 Subject: [PATCH] =?UTF-8?q?UI:=208=EA=B1=B4=20=ED=94=BC=EB=93=9C=EB=B0=B1?= =?UTF-8?q?=20=EB=B0=98=EC=98=81=20+=20Spot=20=EB=B4=89=EC=9D=B8=20+=20?= =?UTF-8?q?=ED=8E=98=EC=96=B4=EB=A7=81=20=EC=B2=B4=ED=81=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [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) --- .../medilightv2android/ble/BleManager.kt | 3 + .../ui/views/connection/DeviceScanView.kt | 15 ++- .../views/monitoring/PiezoMonitoringView.kt | 67 ++++++----- .../ui/views/monitoring/PlacementGuideView.kt | 112 ++++++------------ 4 files changed, 92 insertions(+), 105 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt index 4aa2911..6cf1a1a 100644 --- a/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt +++ b/app/src/main/java/com/example/medilightv2android/ble/BleManager.kt @@ -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(null) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt index 2985bfe..12841b1 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/connection/DeviceScanView.kt @@ -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." + } } } 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 af83bde..a806b3a 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 @@ -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) { 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 6e66f4b..06f0902 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 @@ -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)