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 d3eb3db..c06194d 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 @@ -1,7 +1,10 @@ package com.example.medilightv2android.ui.views.monitoring import android.util.Log +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* @@ -29,7 +32,9 @@ import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight @@ -164,31 +169,43 @@ fun PlacementGuideView(appState: AppState) { .background(bgColor) .background(if (isGreenZone) Brush.verticalGradient(listOf(bgColor, bgColor)) else gradientBrush) ) { - // Top bar - Row( + // Top bar — 화면 절대 가운데 정렬을 위해 Box overlay 구조 + Box( modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically + contentAlignment = Alignment.Center ) { - IconButton(onClick = { appState.backFromPlacementGuide() }) { - Icon( - Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = "Back", - tint = MlPrimary - ) - } - // 헤더 타이틀 제거 — 본문 큰 타이틀("Sensor Alignment")과 중복되어 시각적 잡음. - Spacer(modifier = Modifier.weight(1f)) - if (isLocked) { - Icon(Icons.Filled.CheckCircle, "Ready", tint = Color(0xFF4CAF50), modifier = Modifier.size(24.dp)) - Spacer(modifier = Modifier.width(4.dp)) - } - if (appState.isDevMode) { - IconButton(onClick = { showPlacementSettings = !showPlacementSettings }) { - Icon(Icons.Default.Settings, "Settings", tint = if (showPlacementSettings) MlPrimary else MlSecondaryText) + // (A) ← 좌측 + 우측 status icons (Row) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + IconButton(onClick = { appState.backFromPlacementGuide() }) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + tint = MlPrimary + ) + } + Spacer(modifier = Modifier.weight(1f)) + if (isLocked) { + Icon(Icons.Filled.CheckCircle, "Ready", tint = Color(0xFF4CAF50), modifier = Modifier.size(24.dp)) + Spacer(modifier = Modifier.width(4.dp)) + } + if (appState.isDevMode) { + IconButton(onClick = { showPlacementSettings = !showPlacementSettings }) { + Icon(Icons.Default.Settings, "Settings", tint = if (showPlacementSettings) MlPrimary else MlSecondaryText) + } } } + // (B) 타이틀 — Box.Center로 화면 절대 가운데 (← 폭과 무관) + Text( + text = "Sensor Alignment", + fontSize = 26.sp, + fontWeight = FontWeight.ExtraBold, + color = Color.Black + ) } // 펌웨어 구버전 경고 @@ -274,15 +291,7 @@ fun PlacementGuideView(appState: AppState) { } } - // 타이틀 — 28sp Bold (Hint와의 강조 균형. 이전 38sp ExtraBold는 화면을 압도) - Text( - text = "Sensor Alignment", - fontSize = 36.sp, - fontWeight = FontWeight.ExtraBold, - color = Color.Black, - textAlign = TextAlign.Center, - modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) - ) + // 타이틀은 top bar 로 이동 (← 옆 절대 가운데) — 본문 중복 제거. // ── 가이드 텍스트 (Step 아래, 몸 위) — "pubic bone" 인라인 빨강 ExtraBold 강조 ── val hintGreen = isGreenZone @@ -326,103 +335,53 @@ fun PlacementGuideView(appState: AppState) { .padding(horizontal = 16.dp, vertical = 12.dp) ) - // 큰 타이틀과 몸 사이 간격 (Body 상단 Spacer 32→8dp) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(2.dp)) - // ── 하복부 Canvas 실루엣 + 센서 + 방향 가이드 ── + // 화살표 깜빡임 — 매 scan refresh 마다 alpha 토글 → 업데이트 가시화. + // (사용자 요청: "refreshing 될 때마다 깜빡이게 — 덥데이트 되는지 확인") + val arrowAlpha by animateFloatAsState( + targetValue = if (scanCount % 2 == 0) 1f else 0.35f, + animationSpec = tween(durationMillis = 180), + label = "arrowBlink" + ) + + // ── 하복부 일러스트(PNG) + Canvas overlay (센서/방광/화살표) ── Box( modifier = Modifier .fillMaxWidth() - // 회의 #2: dev/일반 토르소 크기 통일 — 둘 다 weight(1f) (이전엔 dev만 400dp 고정) - .weight(1f), + .weight(1f) + // 일러스트 비율 2240:1888 ≈ 7:6 (finalstomachillust.png) + .aspectRatio(2240f / 1888f), contentAlignment = Alignment.Center ) { - // Sensor PNG → ImageBitmap (Canvas drawImage로 그려 oval 중심과 정렬) + // 1) 하복부 일러스트 (배경 — 박스 가득) + Image( + painter = painterResource(id = com.example.medilightv2android.R.drawable.body_silhouette), + contentDescription = "Body silhouette", + modifier = Modifier.matchParentSize(), + contentScale = ContentScale.FillBounds + ) + // 2) 센서 PNG (Canvas drawImage로 oval 중심 정렬) val sensorBitmap = remember { android.graphics.BitmapFactory.decodeResource( context.resources, com.example.medilightv2android.R.drawable.sensor_device, ).asImageBitmap() } - // 하복부 실루엣 (Canvas — CKLaw body silhouette) - val bodyAlpha = 0.9f // 항상 동일 (#5) Canvas(modifier = Modifier.fillMaxSize()) { val w = size.width val h = size.height val cx = w / 2f - // Cap body silhouette width by height-derived budget so the - // figure stays portrait-proportioned on wide screens / 가로 모드. - // Portrait phone: bodyW == w (no change). Tablet / landscape: - // bodyW < w, body stays centered with cx = w / 2. - val bodyW = kotlin.math.min(w, h * 0.7f) val greenZone = isGreenZone - // Body silhouette palette - val skinFill = Color(0xFFF2C5A0).copy(alpha = bodyAlpha) - val skinStroke = Color(0xFFBF947A).copy(alpha = bodyAlpha) - val skinShade = Color(0xFF8E6647).copy(alpha = bodyAlpha) - val navelInk = Color(0xFF5A3D26).copy(alpha = bodyAlpha) + // ── 일러스트 내 anatomical landmarks (실측 기준) ── + // finalstomachillust.png: 배꼽 ≈ 화면 위 25%, 사타구니 V ≈ 80% + val navelY = h * 0.25f + val pubisY = h * 0.80f - // ── Anatomical landmarks (vertical %) ── - // 빌드 120-121: 실루엣 Y값 16% UP shift, 비례 유지 (25→9% 또는 6→9% 누적) - val chestY = h * 0.09f - val waistY = h * 0.42f - val hipY = h * 0.76f - val pubisY = h * 0.96f - // bodyW: Charles 833c110 가로 모드 대응 / 비율: 우리 d6b0a6b 다리처럼 안 보이게 조정 - val chestW = bodyW * 0.40f - val waistW = bodyW * 0.36f - val hipW = bodyW * 0.42f - - // ── Torso silhouette — hourglass via cubic bezier ── - val bodyPath = Path().apply { - moveTo(cx - chestW, chestY) - cubicTo( - cx - chestW * 1.05f, chestY + (waistY - chestY) * 0.45f, - cx - waistW * 1.05f, waistY - (waistY - chestY) * 0.20f, - cx - waistW, waistY) - cubicTo( - cx - waistW * 0.98f, waistY + (hipY - waistY) * 0.35f, - cx - hipW * 0.95f, hipY - (hipY - waistY) * 0.20f, - cx - hipW, hipY) - cubicTo( - cx - hipW * 0.92f, hipY + (pubisY - hipY) * 0.55f, - cx - hipW * 0.45f, pubisY, - cx, pubisY) - cubicTo( - cx + hipW * 0.45f, pubisY, - cx + hipW * 0.92f, hipY + (pubisY - hipY) * 0.55f, - cx + hipW, hipY) - cubicTo( - cx + hipW * 0.95f, hipY - (hipY - waistY) * 0.20f, - cx + waistW * 0.98f, waistY + (hipY - waistY) * 0.35f, - cx + waistW, waistY) - cubicTo( - cx + waistW * 1.05f, waistY - (waistY - chestY) * 0.20f, - cx + chestW * 1.05f, chestY + (waistY - chestY) * 0.45f, - cx + chestW, chestY) - close() - } - drawPath(bodyPath, skinFill) - drawPath(bodyPath, skinStroke, style = Stroke(width = 1.6f)) - - // ── Linea alba (복부 정중선) ── - val ribY = chestY + h * 0.10f - val navelY = waistY + (hipY - waistY) * 0.20f - drawLine(skinShade.copy(alpha = 0.30f * bodyAlpha), - Offset(cx, ribY + 4f), Offset(cx, navelY - 6f), - strokeWidth = 1.0f) - - // ── Navel (배꼽) ── - drawCircle(navelInk, 3.5f, Offset(cx, navelY)) - drawCircle(skinShade.copy(alpha = 0.4f * bodyAlpha), 5.5f, Offset(cx, navelY), - style = Stroke(width = 0.8f)) - - // 빌드 111: Pubic arch 라인 + halo + endpoint 제거 — pubic bone 강조는 hint 인라인 1곳에만 - - // ── 방광 타원 (배꼽~치골 사이) ── - val bladderCy = (navelY + pubisY) / 2f - val bladderRx = hipW * 0.42f + // ── 방광 타원 (치골 바로 위) ── + val bladderCy = navelY + (pubisY - navelY) * 0.75f + val bladderRx = w * 0.18f val bladderRy = bladderRx * 0.70f val ovalColor = if (greenZone) Color(0xFF4CAF50) else Color.Cyan drawOval( @@ -457,44 +416,45 @@ fun PlacementGuideView(appState: AppState) { alpha = 0.95f, ) - // ── 방향 화살표 (센서/타원 바깥, 충분한 간격) — 복원 (사용자 요청) ── + // ── 방향 화살표 (센서/타원 바깥) — 매 refresh 마다 alpha 깜빡임 ── + // 색감 #FF5722 → #E64A19 (deep orange 700, 일러스트와 톤 맞춤) + // StrokeCap.Round로 끝부분 둥글게 + 굵기 9→10 (가독성) if (!greenZone) { - val arrowColor = Color(0xFFFF5722) + val arrowColor = Color(0xFFE64A19).copy(alpha = arrowAlpha) val arrowLen = 70f val arrowHead = 28f - val sw = 9f + val sw = 10f + val cap = androidx.compose.ui.graphics.StrokeCap.Round val sensorHalfH = h * 0.08f when { directionIcon.contains("up") -> { 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) + drawLine(arrowColor, Offset(cx, ay + arrowLen), Offset(cx, ay), strokeWidth = sw, cap = cap) + drawLine(arrowColor, Offset(cx - arrowHead, ay + arrowHead), Offset(cx, ay), strokeWidth = sw, cap = cap) + drawLine(arrowColor, Offset(cx + arrowHead, ay + arrowHead), Offset(cx, ay), strokeWidth = sw, cap = cap) } directionIcon.contains("down") -> { 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) + drawLine(arrowColor, Offset(cx, ay - arrowLen), Offset(cx, ay), strokeWidth = sw, cap = cap) + drawLine(arrowColor, Offset(cx - arrowHead, ay - arrowHead), Offset(cx, ay), strokeWidth = sw, cap = cap) + drawLine(arrowColor, Offset(cx + arrowHead, ay - arrowHead), Offset(cx, ay), strokeWidth = sw, cap = cap) } directionIcon.contains("left") -> { 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) + drawLine(arrowColor, Offset(ax + arrowLen, bladderCy), Offset(ax, bladderCy), strokeWidth = sw, cap = cap) + drawLine(arrowColor, Offset(ax + arrowHead, bladderCy - arrowHead), Offset(ax, bladderCy), strokeWidth = sw, cap = cap) + drawLine(arrowColor, Offset(ax + arrowHead, bladderCy + arrowHead), Offset(ax, bladderCy), strokeWidth = sw, cap = cap) } directionIcon.contains("right") -> { 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) + drawLine(arrowColor, Offset(ax - arrowLen, bladderCy), Offset(ax, bladderCy), strokeWidth = sw, cap = cap) + drawLine(arrowColor, Offset(ax - arrowHead, bladderCy - arrowHead), Offset(ax, bladderCy), strokeWidth = sw, cap = cap) + drawLine(arrowColor, Offset(ax - arrowHead, bladderCy + arrowHead), Offset(ax, bladderCy), strokeWidth = sw, cap = cap) } } } } - - // (sensor 이미지는 위 Canvas drawImage 로 이동 — body silhouette과 동일 좌표계) } // (힌트 텍스트는 Step 아래, 몸 위에 배치됨) diff --git a/app/src/main/res/drawable-xxhdpi/body_silhouette.png b/app/src/main/res/drawable-xxhdpi/body_silhouette.png new file mode 100644 index 0000000..2d98bff Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/body_silhouette.png differ