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:
+67
-107
@@ -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 |
Reference in New Issue
Block a user