Sensor Alignment: PNG illust + top-bar title + 화살표 refresh blink (feature-parity)

자산:
  - drawable-xxhdpi/body_silhouette.png 신규 (finalstomachillust.png, 2240×1888 ≈ 7:6)
    feature/tab-navigation 의 일러스트와 동일 — bezier 토르소를 PNG로 교체

UI:
  - Top bar: Box overlay 구조로 ← + "Sensor Alignment" 화면 절대 가운데 (26sp ExtraBold)
  - 본문 큰 타이틀(36sp) 제거 — top bar 와 중복
  - Body 영역: bezier silhouette → Image(painter=body_silhouette) + 얇은 Canvas overlay
    Canvas 는 oval(방광) + sensor PNG + 방향 화살표만 담당
  - 비율 .aspectRatio(2240f / 1888f) 고정 → 화면비 무관 portrait 유지

화살표 refresh blink (사용자 요청):
  - scanCount % 2 토글 → animateFloatAsState(1f ↔ 0.35f, tween 180ms)
  - 매 cycle 마다 화살표 alpha 가 깜빡임 → "업데이트 됐는지" 시각화
  - 색감 #FF5722 → #E64A19 (deep orange 700, 일러스트 톤 매칭)
  - StrokeCap.Round + 굵기 9→10 (가독성)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 14:04:45 +09:00
parent e125e2dd64
commit a6b973b284
2 changed files with 83 additions and 123 deletions
@@ -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,11 +169,16 @@ 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),
contentAlignment = Alignment.Center
) {
// (A) ← 좌측 + 우측 status icons (Row)
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = { appState.backFromPlacementGuide() }) {
@@ -178,7 +188,6 @@ fun PlacementGuideView(appState: AppState) {
tint = MlPrimary
)
}
// 헤더 타이틀 제거 — 본문 큰 타이틀("Sensor Alignment")과 중복되어 시각적 잡음.
Spacer(modifier = Modifier.weight(1f))
if (isLocked) {
Icon(Icons.Filled.CheckCircle, "Ready", tint = Color(0xFF4CAF50), modifier = Modifier.size(24.dp))
@@ -190,6 +199,14 @@ fun PlacementGuideView(appState: AppState) {
}
}
}
// (B) 타이틀 — Box.Center로 화면 절대 가운데 (← 폭과 무관)
Text(
text = "Sensor Alignment",
fontSize = 26.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.Black
)
}
// 펌웨어 구버전 경고
com.example.medilightv2android.ui.components.FirmwareWarningBanner(bleManager)
@@ -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 아래, 몸 위에 배치됨)
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB