refactor(ui): Design Token 중앙화 + AlertModalDialog 컴포넌트화 (Step 1~3)

초 고수 앱 디자이너 코드 리뷰 결과 반영. 저비용/저리스크 항목만 우선 진행,
대형 파일 분해 등은 별도 로드맵.

Step 1: Design Token (Color.kt + Theme.kt)
  - Color.kt 에 semantic token 추가: MlCritical / MlWarning / MlSuccess /
    MlInfo / MlNeutral / MlAccentPurple + ContainerAlpha / ChipAlpha 상수
  - Theme.kt LightColorScheme 를 Purple40 → MlPrimary 로 override.
    기존에는 Material3 default 가 Purple 이라 Button 등 커스텀 UI 와
    색상 불일치 발생. brand 통일.

Step 2: AlertModalDialog 컴포넌트화 (ui/components/AlertModalDialog.kt)
  - 3개 알람 다이얼로그 (Wakeup 주황, Walking 빨강, Detach 주황) 가
    100% 동일 구조 (배경색만 다름) 로 각각 40~50줄 반복.
  - 재사용 가능한 AlertModalDialog(backgroundColor, icon, title,
    subtitle, detail, confirmLabel, onConfirm, widthFraction, iconSize,
    titleSize, dismissible) 로 통합.
  - dismissible=false 가 default (의료 알람은 사용자 명시 확인 필요).
  - PiezoMonitoringView (Wakeup + Walking) / PlacementGuideView (Detach)
    3곳 모두 새 컴포넌트로 교체. 코드량 ~120줄 감소.

Step 3: LabdbAutoRetry 배너 semantic token 적용
  - ClinicalHomeView 자동 업로드 배너의 Color(0xFF1976D2) → MlInfo,
    Color(0xFFFF9800) → MlWarning, Color(0xFF4CAF50) → MlSuccess.
  - ChipAlpha (0.12f) 로 배경 alpha 통일.

효과:
  - 신규 배너/Dialog 는 반드시 semantic token 사용 → 색상 일관성 관리 용이.
  - 색상 변경 시 Color.kt 한 파일만 수정하면 전역 반영.
  - AlertModalDialog 로 신규 알람 추가 시 10~15줄로 가능 (이전 50줄+).

미완료 (별도 로드맵):
  - 기존 파일의 나머지 매직 hex 색상 (~300개) → semantic token 마이그레이션.
    광범위 변경 + 실기 회귀 위험 있어 순차 진행 필요.
  - 대형 파일 분해 (PiezoMonitoringView 2900L, PlacementGuideView 2200L).
  - ViewModel 도입, BLE 콜백 → Flow 이전.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 11:52:37 +09:00
parent 60b630d3de
commit 43ad32ba8a
6 changed files with 222 additions and 146 deletions
@@ -0,0 +1,139 @@
package com.medithings.vesiscan.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
/**
* 전면 모달 알람 다이얼로그 — semantic 배경색 위 아이콘/타이틀/서브/설명 + 확인 버튼.
*
* 통합 대상 (2026-07-10):
* - Wakeup Alarm (주황, LYING → NON_LYING/WALKING 전이)
* - Walking Alarm (빨강, WALKING 전이 즉시 낙상 경고)
* - Detach Alert (주황, 센서 분리)
*
* 사용 예:
* ```
* if (showWakeupAlarm) {
* AlertModalDialog(
* backgroundColor = MlWarning,
* icon = Icons.Default.WbSunny,
* title = stringResource(R.string.wakeup_detected_title),
* subtitle = stringResource(R.string.wakeup_safely_get_up),
* detail = stringResource(R.string.alarm_lying_duration, mins, secs),
* onConfirm = { showWakeupAlarm = false },
* )
* }
* ```
*
* 접근성:
* - 기본은 `dismissOnBackPress = false / dismissOnClickOutside = false`
* (의료 알람은 사용자 명시적 확인 필요). 필요 시 [dismissible]=true 로 override.
*/
@Composable
fun AlertModalDialog(
backgroundColor: Color,
icon: ImageVector,
title: String,
subtitle: String? = null,
detail: String? = null,
confirmLabel: String,
onConfirm: () -> Unit,
widthFraction: Float = 0.92f,
iconSize: androidx.compose.ui.unit.Dp = 56.dp,
titleSize: androidx.compose.ui.unit.TextUnit = 28.sp,
dismissible: Boolean = false,
) {
Dialog(
onDismissRequest = { if (dismissible) onConfirm() },
properties = DialogProperties(
dismissOnBackPress = dismissible,
dismissOnClickOutside = dismissible,
)
) {
Box(
modifier = Modifier
.fillMaxWidth(widthFraction)
.clip(RoundedCornerShape(24.dp))
.background(backgroundColor)
.padding(horizontal = 28.dp, vertical = 36.dp),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = icon,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(iconSize),
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = title,
fontSize = titleSize,
fontWeight = FontWeight.ExtraBold,
color = Color.White,
textAlign = TextAlign.Center,
)
if (subtitle != null) {
Spacer(modifier = Modifier.height(6.dp))
Text(
text = subtitle,
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
color = Color.White.copy(alpha = 0.9f),
textAlign = TextAlign.Center,
)
}
if (detail != null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = detail,
fontSize = 14.sp,
color = Color.White.copy(alpha = 0.85f),
textAlign = TextAlign.Center,
)
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onConfirm,
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = backgroundColor,
),
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
shape = RoundedCornerShape(14.dp),
) {
Text(
text = confirmLabel,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
)
}
}
}
}
}
@@ -2,7 +2,7 @@ package com.medithings.vesiscan.ui.theme
import androidx.compose.ui.graphics.Color
// Material theme colors
// Material theme colors (legacy — Material3 template default. 실사용 X, LightColorScheme 를 MlPrimary 로 override.)
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
@@ -10,7 +10,7 @@ val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
// MediLight theme colors
// ─────────── Brand colors ───────────
val MlPrimary = Color(0xFF008FFF)
val MlTeal = Color(0xFF00DB9F)
val MlBackground = Color(0xFFF5F8FF)
@@ -18,3 +18,27 @@ val MlCardBackground = Color.White
val MlSecondaryText = Color(0xFF80808C)
val MlInputBackground = Color(0xFFF5F8FF)
val MlInputBorder = Color(0xFF00DB9F)
// ─────────── Semantic tokens (2026-07-10) ───────────
// 매직 hex 코드가 각 파일에 흩어져 있어 일관성 관리 어려움 → semantic 이름으로 통일.
// 새 UI 코드는 반드시 아래 토큰을 사용. 기존 코드는 점진적으로 마이그레이션.
/** Critical / danger — 위치 놓침, 기기 응답 없음, 낙상 위험, walking alarm 등. */
val MlCritical = Color(0xFFD32F2F)
/** Warning — 배터리 낮음, firmware 구버전, mtb 응답 지연, 자동 업로드 일부 실패, wakeup 알람 등. */
val MlWarning = Color(0xFFFF9800)
/** Success — GREEN zone, 정렬 완료, 자동 업로드 성공, NON_LYING posture chip 등. */
val MlSuccess = Color(0xFF4CAF50)
/** Info — 자동 업로드 진행 중, "확인 중" 등의 중립 정보. */
val MlInfo = Color(0xFF1976D2)
/** Neutral — UNKNOWN posture, disable 상태의 회색 아이콘 등. */
val MlNeutral = Color(0xFF616161)
/** Purple accent — dev toggle, alignment algo selector 등 dev 전용 UI. */
val MlAccentPurple = Color(0xFF7C3AED)
/** Container / background alpha helpers.
* 카드/배너 배경으로 semantic 색 사용 시 알파 12~15% 가 일관적.
* 사용 예: `MlWarning.copy(alpha = ContainerAlpha)`. */
const val ContainerAlpha = 0.15f
/** 배지/inline 강조 (chip 배경 등). */
const val ChipAlpha = 0.12f
@@ -3,21 +3,25 @@ package com.medithings.vesiscan.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
// 2026-07-10: Purple40 기반 Material3 default 를 MediLight 브랜드 색상으로 override.
// 기존에는 colorScheme.primary = Purple40 (Material 템플릿 기본) 이지만 실제 UI 는
// MlPrimary 사용 → MaterialTheme 를 참조하는 default 컴포넌트 (예: Button 배경) 와
// 커스텀 UI 사이에 색상 불일치 발생했음. brand 색상으로 통일.
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
primary = MlPrimary,
onPrimary = Color.White,
secondary = MlTeal,
onSecondary = Color.White,
tertiary = MlAccentPurple,
onTertiary = Color.White,
background = MlBackground,
onBackground = Color(0xFF1C1B1F),
surface = MlCardBackground,
onSurface = Color(0xFF1C1B1F),
*/
error = MlCritical,
onError = Color.White,
)
@Composable
@@ -144,25 +144,28 @@ fun ClinicalHomeView(appState: AppState) {
// 2026-07-09: 자동 업로드 진행/결과 배너.
if (autoUploadPending > 0 || autoUploadingName != null) {
// 2026-07-10: semantic token 사용 (MlInfo = 진행 중 파랑).
Row(
modifier = Modifier.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF1976D2).copy(alpha = 0.12f))
.background(com.medithings.vesiscan.ui.theme.MlInfo.copy(
alpha = com.medithings.vesiscan.ui.theme.ChipAlpha))
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = Color(0xFF1976D2)
color = com.medithings.vesiscan.ui.theme.MlInfo
)
Spacer(Modifier.width(10.dp))
Column {
Text("자동 업로드 진행 중 · 남은 세션 $autoUploadPending",
fontSize = 13.sp, fontWeight = FontWeight.SemiBold,
color = Color(0xFF1976D2))
color = com.medithings.vesiscan.ui.theme.MlInfo)
autoUploadingName?.let {
Text(it, fontSize = 11.sp, color = Color(0xFF1976D2).copy(alpha = 0.8f),
Text(it, fontSize = 11.sp,
color = com.medithings.vesiscan.ui.theme.MlInfo.copy(alpha = 0.8f),
maxLines = 1)
}
}
@@ -171,7 +174,9 @@ fun ClinicalHomeView(appState: AppState) {
} else if (autoUploadResult != null) {
val msg = autoUploadResult ?: ""
val hasFail = msg.contains("failed")
val bg = if (hasFail) Color(0xFFFF9800) else Color(0xFF4CAF50)
// 2026-07-10: semantic token 사용 (MlWarning 실패 / MlSuccess 완료).
val bg = if (hasFail) com.medithings.vesiscan.ui.theme.MlWarning
else com.medithings.vesiscan.ui.theme.MlSuccess
Row(
modifier = Modifier.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
@@ -156,99 +156,35 @@ fun PiezoMonitoringView(appState: AppState) {
// 직전 walking 상태 추적 — false → true 전이 시 critical 알람
var prevIsWalking by remember { mutableStateOf(false) }
// Wakeup alarm (warning, 주황) — LYING → NON_LYING/WALKING 전이
// 2026-07-10: 3개 알람 Dialog 를 AlertModalDialog 로 통합 (ui/components/AlertModalDialog.kt).
if (showWakeupAlarm) {
androidx.compose.ui.window.Dialog(
onDismissRequest = { },
properties = androidx.compose.ui.window.DialogProperties(
dismissOnBackPress = false, dismissOnClickOutside = false
)
) {
Box(
modifier = Modifier
.fillMaxWidth(0.92f)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xFFFF9800)) // Material Orange 500
.padding(horizontal = 28.dp, vertical = 36.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Warning, contentDescription = null,
tint = Color.White, modifier = Modifier.size(56.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(stringResource(R.string.alarm_patient_woke_up), fontSize = 28.sp,
fontWeight = FontWeight.ExtraBold, color = Color.White,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(6.dp))
Text(stringResource(R.string.alarm_patient_woke_up_en), fontSize = 16.sp,
fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.9f),
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(8.dp))
val mins = wakeupAlarmElapsedSec / 60
val secs = wakeupAlarmElapsedSec % 60
Text(stringResource(R.string.alarm_lying_duration, mins, secs),
fontSize = 14.sp, color = Color.White.copy(alpha = 0.85f))
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { showWakeupAlarm = false },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White, contentColor = Color(0xFFFF9800)
),
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(14.dp)
) {
Text(stringResource(R.string.confirm), fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
}
}
}
com.medithings.vesiscan.ui.components.AlertModalDialog(
backgroundColor = com.medithings.vesiscan.ui.theme.MlWarning,
icon = Icons.Default.Warning,
title = stringResource(R.string.alarm_patient_woke_up),
subtitle = stringResource(R.string.alarm_patient_woke_up_en),
detail = stringResource(R.string.alarm_lying_duration, mins, secs),
confirmLabel = stringResource(R.string.confirm),
onConfirm = { showWakeupAlarm = false },
)
}
// Walking alarm (critical, 빨강) — NON_LYING/LYING → WALKING 전이 (보행 시작)
if (showWalkingAlarm) {
androidx.compose.ui.window.Dialog(
onDismissRequest = { },
properties = androidx.compose.ui.window.DialogProperties(
dismissOnBackPress = false, dismissOnClickOutside = false
com.medithings.vesiscan.ui.components.AlertModalDialog(
backgroundColor = com.medithings.vesiscan.ui.theme.MlCritical,
icon = Icons.Default.DirectionsWalk,
title = stringResource(R.string.alarm_patient_walking),
subtitle = stringResource(R.string.alarm_patient_walking_en),
detail = stringResource(R.string.alarm_immediate_check),
confirmLabel = stringResource(R.string.confirm),
onConfirm = { showWalkingAlarm = false },
widthFraction = 0.94f,
iconSize = 64.dp,
titleSize = 30.sp,
)
) {
Box(
modifier = Modifier
.fillMaxWidth(0.94f)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xFFD32F2F))
.padding(horizontal = 28.dp, vertical = 36.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.DirectionsWalk, contentDescription = null,
tint = Color.White, modifier = Modifier.size(64.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(stringResource(R.string.alarm_patient_walking), fontSize = 30.sp,
fontWeight = FontWeight.ExtraBold, color = Color.White,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(6.dp))
Text(stringResource(R.string.alarm_patient_walking_en),
fontSize = 16.sp, fontWeight = FontWeight.Medium,
color = Color.White.copy(alpha = 0.92f),
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.alarm_immediate_check),
fontSize = 14.sp, color = Color.White.copy(alpha = 0.9f),
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { showWalkingAlarm = false },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White, contentColor = Color(0xFFD32F2F)
),
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(14.dp)
) {
Text(stringResource(R.string.confirm), fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
val screenWidth = LocalConfiguration.current.screenWidthDp
@@ -208,50 +208,18 @@ fun PlacementGuideView(appState: AppState) {
)
// 센서 분리 알림 (주황 warning) — wakeup 알람과 동일 패턴
// 2026-07-10: AlertModalDialog 로 통합 (ui/components/AlertModalDialog.kt).
if (showDetachAlert) {
androidx.compose.ui.window.Dialog(
onDismissRequest = { },
properties = androidx.compose.ui.window.DialogProperties(
dismissOnBackPress = false, dismissOnClickOutside = false
com.medithings.vesiscan.ui.components.AlertModalDialog(
backgroundColor = com.medithings.vesiscan.ui.theme.MlWarning,
icon = Icons.Default.Warning,
title = stringResource(R.string.sensor_detached_title),
subtitle = stringResource(R.string.sensor_detached_subtitle),
detail = stringResource(R.string.sensor_detached_desc),
confirmLabel = stringResource(R.string.confirm),
onConfirm = { showDetachAlert = false },
titleSize = 26.sp,
)
) {
Box(
modifier = Modifier
.fillMaxWidth(0.92f)
.clip(RoundedCornerShape(24.dp))
.background(Color(0xFFFF9800)) // Material Orange 500
.padding(horizontal = 28.dp, vertical = 36.dp),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(Icons.Default.Warning, contentDescription = null,
tint = Color.White, modifier = Modifier.size(56.dp))
Spacer(modifier = Modifier.height(12.dp))
Text(stringResource(R.string.sensor_detached_title), fontSize = 26.sp,
fontWeight = FontWeight.ExtraBold, color = Color.White,
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(6.dp))
Text(stringResource(R.string.sensor_detached_subtitle), fontSize = 16.sp,
fontWeight = FontWeight.Medium, color = Color.White.copy(alpha = 0.9f),
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(8.dp))
Text(stringResource(R.string.sensor_detached_desc),
fontSize = 14.sp, color = Color.White.copy(alpha = 0.85f),
textAlign = TextAlign.Center)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = { showDetachAlert = false },
colors = ButtonDefaults.buttonColors(
containerColor = Color.White, contentColor = Color(0xFFFF9800)
),
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(14.dp)
) {
Text(stringResource(R.string.confirm), fontSize = 20.sp, fontWeight = FontWeight.Bold)
}
}
}
}
}
Column(