From af469558e6cc796595f54b53b4a0b9eb87cad424 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 23 Jun 2026 17:17:58 +0900 Subject: [PATCH] =?UTF-8?q?feat(posture):=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?=EA=B8=B0=EC=83=81=20=EC=95=8C=EB=9E=8C=20(LYING/NON=5FLYING=20?= =?UTF-8?q?+=20Settings=20=ED=86=A0=EA=B8=80=20+=20=EB=B9=A8=EA=B0=84=20?= =?UTF-8?q?=ED=8C=9D=EC=97=85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 요구사항: 설정 탭에서 알람 on/off → on 상태에서 일어남 감지 시 빨간 팝업. 변경: 1) ImuPostureClassifier 2-state 단순화 - 기존: SUPINE/SITTING/STANDING (3 state) - 신규: LYING / NON_LYING (2 state) + UNKNOWN - sitting/standing 구분 불필요 (낙상 예방엔 "누웠나/일어났나" 만 중요) - 분류: |pitch| > threshold → LYING, ≤ → NON_LYING 2) GreenZoneConstants.pitchLyingThreshold (default 25°) - @Volatile var — runtime 변경 가능 - dev 모드 설정에서 slider 로 조정 (10~70°, 11 steps) 3) WakeupAlarmPrefs — SharedPreferences 저장 - isEnabled (default false) - lyingDurationSec (default 180 = 3분) 4) WakeupDetector — state machine - LYING 진입 시 timestamp 기록 - LYING → NON_LYING 전이 시 elapsed 측정 - elapsed ≥ lyingDurationSec 이면 onWakeup 콜백 - 1회 발동 후 LYING 복귀까지 재무장 안 됨 (스팸 방지) - UNKNOWN 은 무시 (motion artifact 흔들림 영향 X) 5) PiezoMonitoringView - posture 아이콘 매핑 LYING (Hotel, 파랑) / NON_LYING (AccessibilityNew, 초록) / UNKNOWN - imuCollector.onComplete 콜백에서 wakeupDetector.update(posture) 호출 - showWakeupAlarm Boolean state + 빨간 Dialog (Color 0xFFD32F2F) · Warning 아이콘 + "환자가 일어났습니다" 메세지 · 누운 시간 표시 · 확인 버튼 (사용자 dismiss 필수, 자동 닫힘 X) · dismissOnBackPress/ClickOutside = false (실수 dismiss 방지) - Demo 모드: posture LYING 고정 (BLE 없음) 6) PiezoPersonalizationView — WakeupAlarmCard 신규 - 빨간 톤 카드 + Switch - "기상 알람 — 환자가 일어나면 빨간 팝업으로 알림 (낙상 예방)" - 토글 즉시 WakeupAlarmPrefs.setEnabled 7) Dev 설정 (PiezoMonitoringView 의 dev panel) - "Pitch Threshold" 슬라이더 추가 (10°~70°) - GreenZoneConstants.pitchLyingThreshold 직접 조정 - 실시간 반영 — 다음 cycle 부터 적용 Co-Authored-By: Claude Opus 4.7 --- .../managers/GreenZoneConstants.kt | 3 + .../managers/ImuPostureClassifier.kt | 43 +++----- .../managers/WakeupDetector.kt | 67 ++++++++++++ .../services/WakeupAlarmPrefs.kt | 42 +++++++ .../views/monitoring/PiezoMonitoringView.kt | 103 ++++++++++++++++-- .../PiezoPersonalizationView.kt | 48 ++++++++ 6 files changed, 266 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/example/medilightv2android/managers/WakeupDetector.kt create mode 100644 app/src/main/java/com/example/medilightv2android/services/WakeupAlarmPrefs.kt diff --git a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt index c500c26..cfc1f9a 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -28,6 +28,9 @@ object GreenZoneConstants { @Volatile var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE @Volatile var alignmentAlgo: AlignmentAlgo = AlignmentAlgo.V1 // dev 토글 — default V1 (legacy) + /** Posture pitch 임계값 (deg). |pitch| > 이 값 → LYING, ≤ 이 값 → NON_LYING. dev 모드에서 조정 가능. */ + @Volatile var pitchLyingThreshold: Float = 25f + // ═══════════════════════════════════════════════════════════ // 신호 범위 // ═══════════════════════════════════════════════════════════ diff --git a/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt b/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt index 490a532..e66b1f2 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/ImuPostureClassifier.kt @@ -1,19 +1,17 @@ /* - * Posture classifier — flowchart `Posture classification [Flow chart]` 기반 (2026-06-22). + * Posture classifier — flowchart `Posture classification` 기반 (2026-06-22). + * + * 단순화: 3 state (SUPINE/SITTING/STANDING) → **2 state (LYING / NON_LYING)**. + * - 낙상 예방 목적상 sitting/standing 구분 불필요. "누웠나, 일어났나" 만 중요. * * 단계: * 1. Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 → motion/bad sample 거부 - * 2. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π - * 3. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부 - * 4. 자세 분류 (|pitch| 기준): - * < 25° → SUPINE (누움) - * 25-70° → SITTING - * > 70° → STANDING - * - * 입력: ImuSample 15개 (한 응답 = 300ms 분량). 각 sample 의 평균에서 분류. - * 출력: Posture (UNKNOWN, SUPINE, SITTING, STANDING). - * - * flowchart 의 `|pitch| > 25; supine` 는 typo 로 판단 — `< 25` 로 해석. + * 2. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부 + * 3. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π + * 4. 분류 (|pitch| 기준, threshold = GreenZoneConstants.pitchLyingThreshold): + * > threshold → LYING (누움) + * ≤ threshold → NON_LYING (앉음/섬) + * threshold default = 25°, dev 모드 설정에서 변경 가능. */ package com.example.medilightv2android.managers @@ -22,7 +20,7 @@ import kotlin.math.abs import kotlin.math.atan import kotlin.math.sqrt -enum class Posture { UNKNOWN, SUPINE, SITTING, STANDING } +enum class Posture { UNKNOWN, LYING, NON_LYING } object ImuPostureClassifier { @@ -31,17 +29,13 @@ object ImuPostureClassifier { private const val ACC_MAG_MAX = 1.2f private const val GYRO_MAG_MAX_DPS = 9.16f - // 자세 분류 임계값 (deg) - private const val PITCH_SUPINE_MAX = 25f - private const val PITCH_STANDING_MIN = 70f - /** 자세 + 진단 정보 (UI/log용). */ data class Result( val posture: Posture, val pitchDeg: Float, val accMag: Float, val gyroMagDps: Float, - val reason: String, // why UNKNOWN, or "ok" + val reason: String, ) fun classify(samples: List): Result { @@ -49,7 +43,6 @@ object ImuPostureClassifier { return Result(Posture.UNKNOWN, 0f, 0f, 0f, "no samples") } - // sample 평균 var sumAx = 0f; var sumAy = 0f; var sumAz = 0f var sumGx = 0f; var sumGy = 0f; var sumGz = 0f for (s in samples) { @@ -63,28 +56,20 @@ object ImuPostureClassifier { val accMag = sqrt(ax * ax + ay * ay + az * az) val gyroMag = sqrt(gx * gx + gy * gy + gz * gz) - // 1. Acc magnitude 게이트 if (accMag < ACC_MAG_MIN || accMag > ACC_MAG_MAX) { return Result(Posture.UNKNOWN, 0f, accMag, gyroMag, "acc mag out of range (%.2f)".format(accMag)) } - - // 3. Gyro magnitude 게이트 (motion 거부) if (gyroMag > GYRO_MAG_MAX_DPS) { return Result(Posture.UNKNOWN, 0f, accMag, gyroMag, "moving (gyro %.1f dps)".format(gyroMag)) } - // 2. Pitch 계산 val pitch = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat() val absPitch = abs(pitch) + val thr = GreenZoneConstants.pitchLyingThreshold - // 4. 분류 - val posture = when { - absPitch < PITCH_SUPINE_MAX -> Posture.SUPINE - absPitch <= PITCH_STANDING_MIN -> Posture.SITTING - else -> Posture.STANDING - } + val posture = if (absPitch > thr) Posture.LYING else Posture.NON_LYING return Result(posture, pitch, accMag, gyroMag, "ok") } } diff --git a/app/src/main/java/com/example/medilightv2android/managers/WakeupDetector.kt b/app/src/main/java/com/example/medilightv2android/managers/WakeupDetector.kt new file mode 100644 index 0000000..2dbc795 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/managers/WakeupDetector.kt @@ -0,0 +1,67 @@ +/* + * WakeupDetector — LYING → NON_LYING 전이 감지 (낙상 예방용 기상 알람). + * + * 로직: + * - 사용자가 LYING 상태로 들어가면 startTimestamp 기록 + * - LYING 으로 lyingDurationSec(default 180) 이상 유지 후 NON_LYING 으로 전환 → 기상 인정 + * - 기상 인정 시 onWakeup(elapsedSec) 콜백 호출 (한 번) + * - 알람 발동 후 다시 LYING 으로 돌아가야 재무장 (재발동 가능) + * + * UNKNOWN 상태는 무시 (직전 상태 유지). 자세 분류 게이트 실패 시 잠깐 ?_ 떠도 detector 영향 X. + * + * 사용: + * val detector = WakeupDetector(lyingDurationSec = 180) + * detector.onWakeup = { elapsedSec -> showAlarm() } + * // 매 posture 갱신 시: + * detector.update(currentPosture, System.currentTimeMillis()) + */ +package com.example.medilightv2android.managers + +class WakeupDetector( + /** 알람 발동을 위해 직전 LYING 상태로 유지되어야 하는 최소 시간 (초). */ + var lyingDurationSec: Int = 180, +) { + /** 일어남 감지 시 호출 — argument 는 직전 LYING 유지 시간 (초). */ + var onWakeup: ((elapsedSec: Long) -> Unit)? = null + + private var lastPosture: Posture = Posture.UNKNOWN + private var lyingStartMs: Long = 0L // 현재 LYING streak 시작 시각 (0 = LYING 아님) + private var armed: Boolean = true // false 면 알람 1회 발동 후 LYING 복귀 대기 + + /** 외부 reset (Settings 토글 변경 등). */ + fun reset() { + lastPosture = Posture.UNKNOWN + lyingStartMs = 0L + armed = true + } + + /** 현재 자세 + 현재 시각으로 갱신. UNKNOWN 은 직전 상태 유지. */ + fun update(posture: Posture, nowMs: Long = System.currentTimeMillis()) { + if (posture == Posture.UNKNOWN) return + + val prev = lastPosture + lastPosture = posture + + when { + // LYING 진입 — start timestamp 기록 + 재무장 + posture == Posture.LYING && prev != Posture.LYING -> { + lyingStartMs = nowMs + armed = true + } + // LYING 유지 — 그대로 + posture == Posture.LYING -> { /* keep streak */ } + // LYING → NON_LYING 전이 — 알람 판정 + posture == Posture.NON_LYING && prev == Posture.LYING -> { + val elapsedMs = nowMs - lyingStartMs + val elapsedSec = elapsedMs / 1000 + if (armed && lyingStartMs > 0 && elapsedSec >= lyingDurationSec) { + armed = false + onWakeup?.invoke(elapsedSec) + } + lyingStartMs = 0L + } + // NON_LYING 유지 — 그대로 + else -> { /* nothing */ } + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/services/WakeupAlarmPrefs.kt b/app/src/main/java/com/example/medilightv2android/services/WakeupAlarmPrefs.kt new file mode 100644 index 0000000..af39624 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/services/WakeupAlarmPrefs.kt @@ -0,0 +1,42 @@ +/* + * WakeupAlarmPrefs — 기상 알람 설정 persistence (SharedPreferences). + * + * 사용자가 "설정 탭" 에서 toggle on/off → 일어남 감지 시 빨간 팝업. + * LYING 유지 시간 임계 (default 3 min) 도 함께. + */ +package com.example.medilightv2android.services + +import android.content.Context +import android.content.SharedPreferences + +object WakeupAlarmPrefs { + + private const val PREF_NAME = "wakeup_alarm_prefs" + private const val KEY_ENABLED = "enabled" + private const val KEY_LYING_DURATION_SEC = "lying_duration_sec" + + private var prefs: SharedPreferences? = null + + private fun ensure(context: Context): SharedPreferences { + val p = prefs + if (p != null) return p + val newP = context.applicationContext.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE) + prefs = newP + return newP + } + + fun isEnabled(context: Context): Boolean = + ensure(context).getBoolean(KEY_ENABLED, false) + + fun setEnabled(context: Context, enabled: Boolean) { + ensure(context).edit().putBoolean(KEY_ENABLED, enabled).apply() + } + + /** 일어남으로 인정하기 위한 직전 LYING 유지 시간 (초). default 180 = 3분. */ + fun lyingDurationSec(context: Context): Int = + ensure(context).getInt(KEY_LYING_DURATION_SEC, 180) + + fun setLyingDurationSec(context: Context, sec: Int) { + ensure(context).edit().putInt(KEY_LYING_DURATION_SEC, sec).apply() + } +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt index 62a657f..9ad1682 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PiezoMonitoringView.kt @@ -88,6 +88,9 @@ fun PiezoMonitoringView(appState: AppState) { val useScrollLayout = showDevPanels // enableScroll: 스크롤 활성화 여부는 dev 모드면 무조건 ON (panel 접어도 토글 버튼 reachable). val enableScroll = showDevPanels || appState.isDevMode + // Phase 2 — 기상 알람 빨간 팝업 (사용자가 dismiss 해야 닫힘) + // showWakeupAlarm 은 아래에서 wakeupDetector.onWakeup 콜백이 set. + // 빌드 108: Voiding 기록 후 큰 확인 다이얼로그 (1.5초 자동 닫힘) var showRecordedDialog by remember { mutableStateOf(false) } if (showRecordedDialog) { @@ -127,8 +130,69 @@ fun PiezoMonitoringView(appState: AppState) { var lastAllWalls by remember { mutableStateOf?>>(List(6) { null }) } val analyzer = remember { PiezoEchoAnalyzer.shared } - // Posture (flowchart-based, IMU polling — Phase 1) + // Posture (flowchart-based, IMU polling — Phase 1) + Wakeup detector (Phase 2) var currentPosture by remember { mutableStateOf(com.example.medilightv2android.managers.Posture.UNKNOWN) } + var showWakeupAlarm by remember { mutableStateOf(false) } + var wakeupAlarmElapsedSec by remember { mutableStateOf(0L) } + val wakeupDetector = remember { + com.example.medilightv2android.managers.WakeupDetector( + lyingDurationSec = com.example.medilightv2android.services.WakeupAlarmPrefs.lyingDurationSec(context) + ).apply { + onWakeup = { elapsed -> + if (com.example.medilightv2android.services.WakeupAlarmPrefs.isEnabled(context)) { + wakeupAlarmElapsedSec = elapsed + showWakeupAlarm = true + } + } + } + } + // Wakeup alarm 빨간 팝업 — 사용자 dismiss 필요 (자동 닫힘 X) + if (showWakeupAlarm) { + androidx.compose.ui.window.Dialog( + onDismissRequest = { /* not dismissible by outside tap */ }, + properties = androidx.compose.ui.window.DialogProperties( + dismissOnBackPress = false, dismissOnClickOutside = false + ) + ) { + Box( + modifier = Modifier + .fillMaxWidth(0.92f) + .clip(RoundedCornerShape(24.dp)) + .background(Color(0xFFD32F2F)) + .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("환자가 일어났습니다", fontSize = 28.sp, + fontWeight = FontWeight.ExtraBold, color = Color.White, + textAlign = TextAlign.Center) + Spacer(modifier = Modifier.height(6.dp)) + Text("Patient is getting up", 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("누운 시간 ${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(0xFFD32F2F) + ), + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(14.dp) + ) { + Text("확인", fontSize = 20.sp, fontWeight = FontWeight.Bold) + } + } + } + } + } val screenWidth = LocalConfiguration.current.screenWidthDp val screenHeight = LocalConfiguration.current.screenHeightDp @@ -598,13 +662,14 @@ fun PiezoMonitoringView(appState: AppState) { LaunchedEffect(isConnected, isDemoMode) { Log.d("ImuDbg", "LaunchedEffect start isConnected=$isConnected isDemoMode=$isDemoMode") if (isDemoMode) { - currentPosture = com.example.medilightv2android.managers.Posture.SUPINE + currentPosture = com.example.medilightv2android.managers.Posture.LYING return@LaunchedEffect } bleManager.imuCollector.onComplete = { samples -> val r = com.example.medilightv2android.managers.ImuPostureClassifier.classify(samples) if (r.posture != com.example.medilightv2android.managers.Posture.UNKNOWN) { currentPosture = r.posture + wakeupDetector.update(r.posture) } Log.d("ImuDbg", "POSTURE ${r.posture} pitch=${"%.1f".format(r.pitchDeg)}° accMag=${"%.2f".format(r.accMag)}g gyro=${"%.1f".format(r.gyroMagDps)}dps (${r.reason}) samples=${samples.size}") } @@ -951,21 +1016,18 @@ fun PiezoMonitoringView(appState: AppState) { // Posture icon (Phase 1) — flowchart-based 자세 분류 결과 표시. // SUPINE/SITTING/STANDING/UNKNOWN 에 따라 아이콘+색 변경. val postureIcon = when (currentPosture) { - com.example.medilightv2android.managers.Posture.SUPINE -> Icons.Default.Hotel - com.example.medilightv2android.managers.Posture.SITTING -> Icons.Default.AirlineSeatReclineNormal - com.example.medilightv2android.managers.Posture.STANDING -> Icons.Default.AccessibilityNew + com.example.medilightv2android.managers.Posture.LYING -> Icons.Default.Hotel + com.example.medilightv2android.managers.Posture.NON_LYING -> Icons.Default.AccessibilityNew com.example.medilightv2android.managers.Posture.UNKNOWN -> Icons.Default.HelpOutline } val postureTint = when (currentPosture) { - com.example.medilightv2android.managers.Posture.SUPINE -> Color(0xFF2196F3) - com.example.medilightv2android.managers.Posture.SITTING -> Color(0xFFFF9800) - com.example.medilightv2android.managers.Posture.STANDING -> Color(0xFF4CAF50) + com.example.medilightv2android.managers.Posture.LYING -> Color(0xFF2196F3) + com.example.medilightv2android.managers.Posture.NON_LYING -> Color(0xFF4CAF50) com.example.medilightv2android.managers.Posture.UNKNOWN -> Color.Gray.copy(alpha = 0.5f) } val postureLabel = when (currentPosture) { - com.example.medilightv2android.managers.Posture.SUPINE -> "Lying" - com.example.medilightv2android.managers.Posture.SITTING -> "Sitting" - com.example.medilightv2android.managers.Posture.STANDING -> "Standing" + com.example.medilightv2android.managers.Posture.LYING -> "Lying" + com.example.medilightv2android.managers.Posture.NON_LYING -> "Upright" com.example.medilightv2android.managers.Posture.UNKNOWN -> "—" } Row(verticalAlignment = Alignment.CenterVertically) { @@ -1713,6 +1775,25 @@ fun PiezoMonitoringView(appState: AppState) { steps = 8, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary) ) + + HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding)) + + // Posture pitch threshold (LYING vs NON_LYING 분류) + var pitchThrVal by remember { mutableStateOf(GreenZoneConstants.pitchLyingThreshold) } + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Pitch Threshold", fontSize = labelSize, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.width(8.dp)) + Text("${pitchThrVal.toInt()}°", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary) + Spacer(modifier = Modifier.weight(1f)) + Text("|pitch|>${pitchThrVal.toInt()}°→Lying", fontSize = 11.sp, color = MlSecondaryText) + } + Slider( + value = pitchThrVal, + onValueChange = { pitchThrVal = it; GreenZoneConstants.pitchLyingThreshold = it }, + valueRange = 10f..70f, + steps = 11, + colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary) + ) } HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding)) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/personalization/PiezoPersonalizationView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/personalization/PiezoPersonalizationView.kt index 2153b78..28e2d1d 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/personalization/PiezoPersonalizationView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/personalization/PiezoPersonalizationView.kt @@ -79,6 +79,7 @@ fun PiezoPersonalizationView(appState: AppState) { MaxVolumeCard(appState) CatheterThresholdCard(appState) AutoScanIntervalCard(appState) + WakeupAlarmCard() Spacer(modifier = Modifier.height(4.dp)) } @@ -329,3 +330,50 @@ private fun StepperButton(icon: androidx.compose.ui.graphics.vector.ImageVector, Icon(icon, null, tint = Color.White, modifier = Modifier.size(28.dp)) } } + +@androidx.compose.runtime.Composable +private fun WakeupAlarmCard() { + val context = LocalContext.current + var enabled by androidx.compose.runtime.remember { + androidx.compose.runtime.mutableStateOf( + com.example.medilightv2android.services.WakeupAlarmPrefs.isEnabled(context) + ) + } + Card( + modifier = androidx.compose.ui.Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFFD32F2F).copy(alpha = 0.06f)) + ) { + androidx.compose.foundation.layout.Row( + modifier = androidx.compose.ui.Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 18.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + androidx.compose.foundation.layout.Column( + modifier = androidx.compose.ui.Modifier.weight(1f) + ) { + androidx.compose.material3.Text( + "기상 알람", fontSize = 24.sp, fontWeight = FontWeight.ExtraBold + ) + androidx.compose.foundation.layout.Spacer( + modifier = androidx.compose.ui.Modifier.height(4.dp) + ) + androidx.compose.material3.Text( + "환자가 일어나면 빨간 팝업으로 알림 (낙상 예방)", + fontSize = 12.sp, color = MlSecondaryText + ) + } + androidx.compose.material3.Switch( + checked = enabled, + onCheckedChange = { + enabled = it + com.example.medilightv2android.services.WakeupAlarmPrefs.setEnabled(context, it) + }, + colors = androidx.compose.material3.SwitchDefaults.colors( + checkedTrackColor = Color(0xFFD32F2F) + ) + ) + } + } +}