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:
@@ -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)
|
||||
|
||||
|
||||
+14
-1
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+40
-27
@@ -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) {
|
||||
|
||||
+35
-77
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user