diff --git a/app/src/main/java/com/medithings/vesiscan/ui/components/AlertModalDialog.kt b/app/src/main/java/com/medithings/vesiscan/ui/components/AlertModalDialog.kt new file mode 100644 index 0000000..dc51ecb --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/components/AlertModalDialog.kt @@ -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, + ) + } + } + } + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/theme/Color.kt b/app/src/main/java/com/medithings/vesiscan/ui/theme/Color.kt index 563572d..0d9eedd 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/theme/Color.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/theme/Color.kt @@ -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 diff --git a/app/src/main/java/com/medithings/vesiscan/ui/theme/Theme.kt b/app/src/main/java/com/medithings/vesiscan/ui/theme/Theme.kt index 544080d..f597429 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/theme/Theme.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/theme/Theme.kt @@ -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 diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalHomeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalHomeView.kt index 8e176b5..19d4e6b 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalHomeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalHomeView.kt @@ -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)) diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index 791264e..d940cc2 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -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) - } - } - } - } + val mins = wakeupAlarmElapsedSec / 60 + val secs = wakeupAlarmElapsedSec % 60 + 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 - ) - ) { - 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) - } - } - } - } + 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, + ) } val screenWidth = LocalConfiguration.current.screenWidthDp diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt index 3e43aa1..694643d 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PlacementGuideView.kt @@ -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 - ) - ) { - 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) - } - } - } - } + 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, + ) } Column(