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 package com.example.medilightv2android.ui.views.monitoring
import android.util.Log 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.Canvas
import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* 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.asImageBitmap
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -164,31 +169,43 @@ fun PlacementGuideView(appState: AppState) {
.background(bgColor) .background(bgColor)
.background(if (isGreenZone) Brush.verticalGradient(listOf(bgColor, bgColor)) else gradientBrush) .background(if (isGreenZone) Brush.verticalGradient(listOf(bgColor, bgColor)) else gradientBrush)
) { ) {
// Top bar // Top bar — 화면 절대 가운데 정렬을 위해 Box overlay 구조
Row( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp), .padding(horizontal = 24.dp, vertical = 16.dp),
verticalAlignment = Alignment.CenterVertically contentAlignment = Alignment.Center
) { ) {
IconButton(onClick = { appState.backFromPlacementGuide() }) { // (A) ← 좌측 + 우측 status icons (Row)
Icon( Row(
Icons.AutoMirrored.Filled.ArrowBack, modifier = Modifier.fillMaxWidth(),
contentDescription = "Back", verticalAlignment = Alignment.CenterVertically
tint = MlPrimary ) {
) IconButton(onClick = { appState.backFromPlacementGuide() }) {
} Icon(
// 헤더 타이틀 제거 — 본문 큰 타이틀("Sensor Alignment")과 중복되어 시각적 잡음. Icons.AutoMirrored.Filled.ArrowBack,
Spacer(modifier = Modifier.weight(1f)) contentDescription = "Back",
if (isLocked) { tint = MlPrimary
Icon(Icons.Filled.CheckCircle, "Ready", tint = Color(0xFF4CAF50), modifier = Modifier.size(24.dp)) )
Spacer(modifier = Modifier.width(4.dp)) }
} Spacer(modifier = Modifier.weight(1f))
if (appState.isDevMode) { if (isLocked) {
IconButton(onClick = { showPlacementSettings = !showPlacementSettings }) { Icon(Icons.Filled.CheckCircle, "Ready", tint = Color(0xFF4CAF50), modifier = Modifier.size(24.dp))
Icon(Icons.Default.Settings, "Settings", tint = if (showPlacementSettings) MlPrimary else MlSecondaryText) 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는 화면을 압도) // 타이틀은 top bar 로 이동 (← 옆 절대 가운데) — 본문 중복 제거.
Text(
text = "Sensor Alignment",
fontSize = 36.sp,
fontWeight = FontWeight.ExtraBold,
color = Color.Black,
textAlign = TextAlign.Center,
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
)
// ── 가이드 텍스트 (Step 아래, 몸 위) — "pubic bone" 인라인 빨강 ExtraBold 강조 ── // ── 가이드 텍스트 (Step 아래, 몸 위) — "pubic bone" 인라인 빨강 ExtraBold 강조 ──
val hintGreen = isGreenZone val hintGreen = isGreenZone
@@ -326,103 +335,53 @@ fun PlacementGuideView(appState: AppState) {
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
) )
// 큰 타이틀과 몸 사이 간격 (Body 상단 Spacer 32→8dp) Spacer(modifier = Modifier.height(2.dp))
Spacer(modifier = Modifier.height(8.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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
// 회의 #2: dev/일반 토르소 크기 통일 — 둘 다 weight(1f) (이전엔 dev만 400dp 고정) .weight(1f)
.weight(1f), // 일러스트 비율 2240:1888 ≈ 7:6 (finalstomachillust.png)
.aspectRatio(2240f / 1888f),
contentAlignment = Alignment.Center 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 { val sensorBitmap = remember {
android.graphics.BitmapFactory.decodeResource( android.graphics.BitmapFactory.decodeResource(
context.resources, context.resources,
com.example.medilightv2android.R.drawable.sensor_device, com.example.medilightv2android.R.drawable.sensor_device,
).asImageBitmap() ).asImageBitmap()
} }
// 하복부 실루엣 (Canvas — CKLaw body silhouette)
val bodyAlpha = 0.9f // 항상 동일 (#5)
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(modifier = Modifier.fillMaxSize()) {
val w = size.width val w = size.width
val h = size.height val h = size.height
val cx = w / 2f 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 val greenZone = isGreenZone
// Body silhouette palette // ── 일러스트 내 anatomical landmarks (실측 기준) ──
val skinFill = Color(0xFFF2C5A0).copy(alpha = bodyAlpha) // finalstomachillust.png: 배꼽 ≈ 화면 위 25%, 사타구니 V ≈ 80%
val skinStroke = Color(0xFFBF947A).copy(alpha = bodyAlpha) val navelY = h * 0.25f
val skinShade = Color(0xFF8E6647).copy(alpha = bodyAlpha) val pubisY = h * 0.80f
val navelInk = Color(0xFF5A3D26).copy(alpha = bodyAlpha)
// ── Anatomical landmarks (vertical %) ── // ── 방광 타원 (치골 바로 위) ──
// 빌드 120-121: 실루엣 Y값 16% UP shift, 비례 유지 (25→9% 또는 6→9% 누적) val bladderCy = navelY + (pubisY - navelY) * 0.75f
val chestY = h * 0.09f val bladderRx = w * 0.18f
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 bladderRy = bladderRx * 0.70f val bladderRy = bladderRx * 0.70f
val ovalColor = if (greenZone) Color(0xFF4CAF50) else Color.Cyan val ovalColor = if (greenZone) Color(0xFF4CAF50) else Color.Cyan
drawOval( drawOval(
@@ -457,44 +416,45 @@ fun PlacementGuideView(appState: AppState) {
alpha = 0.95f, alpha = 0.95f,
) )
// ── 방향 화살표 (센서/타원 바깥, 충분한 간격) — 복원 (사용자 요청) ── // ── 방향 화살표 (센서/타원 바깥) — 매 refresh 마다 alpha 깜빡임 ──
// 색감 #FF5722 → #E64A19 (deep orange 700, 일러스트와 톤 맞춤)
// StrokeCap.Round로 끝부분 둥글게 + 굵기 9→10 (가독성)
if (!greenZone) { if (!greenZone) {
val arrowColor = Color(0xFFFF5722) val arrowColor = Color(0xFFE64A19).copy(alpha = arrowAlpha)
val arrowLen = 70f val arrowLen = 70f
val arrowHead = 28f val arrowHead = 28f
val sw = 9f val sw = 10f
val cap = androidx.compose.ui.graphics.StrokeCap.Round
val sensorHalfH = h * 0.08f val sensorHalfH = h * 0.08f
when { when {
directionIcon.contains("up") -> { directionIcon.contains("up") -> {
val ay = bladderCy - bladderRy - sensorHalfH - 20f val ay = bladderCy - bladderRy - sensorHalfH - 20f
drawLine(arrowColor, Offset(cx, ay + arrowLen), 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) 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) drawLine(arrowColor, Offset(cx + arrowHead, ay + arrowHead), Offset(cx, ay), strokeWidth = sw, cap = cap)
} }
directionIcon.contains("down") -> { directionIcon.contains("down") -> {
val ay = bladderCy + bladderRy + sensorHalfH + 20f val ay = bladderCy + bladderRy + sensorHalfH + 20f
drawLine(arrowColor, Offset(cx, ay - arrowLen), 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) 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) drawLine(arrowColor, Offset(cx + arrowHead, ay - arrowHead), Offset(cx, ay), strokeWidth = sw, cap = cap)
} }
directionIcon.contains("left") -> { directionIcon.contains("left") -> {
val ax = cx - bladderRx - 30f val ax = cx - bladderRx - 30f
drawLine(arrowColor, Offset(ax + arrowLen, bladderCy), 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) 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) drawLine(arrowColor, Offset(ax + arrowHead, bladderCy + arrowHead), Offset(ax, bladderCy), strokeWidth = sw, cap = cap)
} }
directionIcon.contains("right") -> { directionIcon.contains("right") -> {
val ax = cx + bladderRx + 30f val ax = cx + bladderRx + 30f
drawLine(arrowColor, Offset(ax - arrowLen, bladderCy), 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) 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) drawLine(arrowColor, Offset(ax - arrowHead, bladderCy + arrowHead), Offset(ax, bladderCy), strokeWidth = sw, cap = cap)
} }
} }
} }
} }
// (sensor 이미지는 위 Canvas drawImage 로 이동 — body silhouette과 동일 좌표계)
} }
// (힌트 텍스트는 Step 아래, 몸 위에 배치됨) // (힌트 텍스트는 Step 아래, 몸 위에 배치됨)
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB