feat(posture): Phase 2 — 기상 알람 (LYING/NON_LYING + Settings 토글 + 빨간 팝업)
요구사항: 설정 탭에서 알람 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 <noreply@anthropic.com>
This commit is contained in:
@@ -28,6 +28,9 @@ object GreenZoneConstants {
|
|||||||
@Volatile var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE
|
@Volatile var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE
|
||||||
@Volatile var alignmentAlgo: AlignmentAlgo = AlignmentAlgo.V1 // dev 토글 — default V1 (legacy)
|
@Volatile var alignmentAlgo: AlignmentAlgo = AlignmentAlgo.V1 // dev 토글 — default V1 (legacy)
|
||||||
|
|
||||||
|
/** Posture pitch 임계값 (deg). |pitch| > 이 값 → LYING, ≤ 이 값 → NON_LYING. dev 모드에서 조정 가능. */
|
||||||
|
@Volatile var pitchLyingThreshold: Float = 25f
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
// 신호 범위
|
// 신호 범위
|
||||||
// ═══════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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 거부
|
* 1. Acc magnitude 게이트: 0.8 ≤ |a| ≤ 1.2 → motion/bad sample 거부
|
||||||
* 2. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π
|
* 2. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부
|
||||||
* 3. Gyro magnitude 게이트: |g| ≤ 9.16 dps → 회전 중 sample 거부
|
* 3. Pitch 계산: atan(ax / sqrt(ay² + az²)) × 180/π
|
||||||
* 4. 자세 분류 (|pitch| 기준):
|
* 4. 분류 (|pitch| 기준, threshold = GreenZoneConstants.pitchLyingThreshold):
|
||||||
* < 25° → SUPINE (누움)
|
* > threshold → LYING (누움)
|
||||||
* 25-70° → SITTING
|
* ≤ threshold → NON_LYING (앉음/섬)
|
||||||
* > 70° → STANDING
|
* threshold default = 25°, dev 모드 설정에서 변경 가능.
|
||||||
*
|
|
||||||
* 입력: ImuSample 15개 (한 응답 = 300ms 분량). 각 sample 의 평균에서 분류.
|
|
||||||
* 출력: Posture (UNKNOWN, SUPINE, SITTING, STANDING).
|
|
||||||
*
|
|
||||||
* flowchart 의 `|pitch| > 25; supine` 는 typo 로 판단 — `< 25` 로 해석.
|
|
||||||
*/
|
*/
|
||||||
package com.example.medilightv2android.managers
|
package com.example.medilightv2android.managers
|
||||||
|
|
||||||
@@ -22,7 +20,7 @@ import kotlin.math.abs
|
|||||||
import kotlin.math.atan
|
import kotlin.math.atan
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
enum class Posture { UNKNOWN, SUPINE, SITTING, STANDING }
|
enum class Posture { UNKNOWN, LYING, NON_LYING }
|
||||||
|
|
||||||
object ImuPostureClassifier {
|
object ImuPostureClassifier {
|
||||||
|
|
||||||
@@ -31,17 +29,13 @@ object ImuPostureClassifier {
|
|||||||
private const val ACC_MAG_MAX = 1.2f
|
private const val ACC_MAG_MAX = 1.2f
|
||||||
private const val GYRO_MAG_MAX_DPS = 9.16f
|
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용). */
|
/** 자세 + 진단 정보 (UI/log용). */
|
||||||
data class Result(
|
data class Result(
|
||||||
val posture: Posture,
|
val posture: Posture,
|
||||||
val pitchDeg: Float,
|
val pitchDeg: Float,
|
||||||
val accMag: Float,
|
val accMag: Float,
|
||||||
val gyroMagDps: Float,
|
val gyroMagDps: Float,
|
||||||
val reason: String, // why UNKNOWN, or "ok"
|
val reason: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
fun classify(samples: List<ImuSample>): Result {
|
fun classify(samples: List<ImuSample>): Result {
|
||||||
@@ -49,7 +43,6 @@ object ImuPostureClassifier {
|
|||||||
return Result(Posture.UNKNOWN, 0f, 0f, 0f, "no samples")
|
return Result(Posture.UNKNOWN, 0f, 0f, 0f, "no samples")
|
||||||
}
|
}
|
||||||
|
|
||||||
// sample 평균
|
|
||||||
var sumAx = 0f; var sumAy = 0f; var sumAz = 0f
|
var sumAx = 0f; var sumAy = 0f; var sumAz = 0f
|
||||||
var sumGx = 0f; var sumGy = 0f; var sumGz = 0f
|
var sumGx = 0f; var sumGy = 0f; var sumGz = 0f
|
||||||
for (s in samples) {
|
for (s in samples) {
|
||||||
@@ -63,28 +56,20 @@ object ImuPostureClassifier {
|
|||||||
val accMag = sqrt(ax * ax + ay * ay + az * az)
|
val accMag = sqrt(ax * ax + ay * ay + az * az)
|
||||||
val gyroMag = sqrt(gx * gx + gy * gy + gz * gz)
|
val gyroMag = sqrt(gx * gx + gy * gy + gz * gz)
|
||||||
|
|
||||||
// 1. Acc magnitude 게이트
|
|
||||||
if (accMag < ACC_MAG_MIN || accMag > ACC_MAG_MAX) {
|
if (accMag < ACC_MAG_MIN || accMag > ACC_MAG_MAX) {
|
||||||
return Result(Posture.UNKNOWN, 0f, accMag, gyroMag,
|
return Result(Posture.UNKNOWN, 0f, accMag, gyroMag,
|
||||||
"acc mag out of range (%.2f)".format(accMag))
|
"acc mag out of range (%.2f)".format(accMag))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Gyro magnitude 게이트 (motion 거부)
|
|
||||||
if (gyroMag > GYRO_MAG_MAX_DPS) {
|
if (gyroMag > GYRO_MAG_MAX_DPS) {
|
||||||
return Result(Posture.UNKNOWN, 0f, accMag, gyroMag,
|
return Result(Posture.UNKNOWN, 0f, accMag, gyroMag,
|
||||||
"moving (gyro %.1f dps)".format(gyroMag))
|
"moving (gyro %.1f dps)".format(gyroMag))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Pitch 계산
|
|
||||||
val pitch = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat()
|
val pitch = (atan(ax / sqrt(ay * ay + az * az)) * 180.0 / Math.PI).toFloat()
|
||||||
val absPitch = abs(pitch)
|
val absPitch = abs(pitch)
|
||||||
|
val thr = GreenZoneConstants.pitchLyingThreshold
|
||||||
|
|
||||||
// 4. 분류
|
val posture = if (absPitch > thr) Posture.LYING else Posture.NON_LYING
|
||||||
val posture = when {
|
|
||||||
absPitch < PITCH_SUPINE_MAX -> Posture.SUPINE
|
|
||||||
absPitch <= PITCH_STANDING_MIN -> Posture.SITTING
|
|
||||||
else -> Posture.STANDING
|
|
||||||
}
|
|
||||||
return Result(posture, pitch, accMag, gyroMag, "ok")
|
return Result(posture, pitch, accMag, gyroMag, "ok")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+92
-11
@@ -88,6 +88,9 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
val useScrollLayout = showDevPanels
|
val useScrollLayout = showDevPanels
|
||||||
// enableScroll: 스크롤 활성화 여부는 dev 모드면 무조건 ON (panel 접어도 토글 버튼 reachable).
|
// enableScroll: 스크롤 활성화 여부는 dev 모드면 무조건 ON (panel 접어도 토글 버튼 reachable).
|
||||||
val enableScroll = showDevPanels || appState.isDevMode
|
val enableScroll = showDevPanels || appState.isDevMode
|
||||||
|
// Phase 2 — 기상 알람 빨간 팝업 (사용자가 dismiss 해야 닫힘)
|
||||||
|
// showWakeupAlarm 은 아래에서 wakeupDetector.onWakeup 콜백이 set.
|
||||||
|
|
||||||
// 빌드 108: Voiding 기록 후 큰 확인 다이얼로그 (1.5초 자동 닫힘)
|
// 빌드 108: Voiding 기록 후 큰 확인 다이얼로그 (1.5초 자동 닫힘)
|
||||||
var showRecordedDialog by remember { mutableStateOf(false) }
|
var showRecordedDialog by remember { mutableStateOf(false) }
|
||||||
if (showRecordedDialog) {
|
if (showRecordedDialog) {
|
||||||
@@ -127,8 +130,69 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
var lastAllWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
|
var lastAllWalls by remember { mutableStateOf<List<Pair<Int, Int>?>>(List(6) { null }) }
|
||||||
val analyzer = remember { PiezoEchoAnalyzer.shared }
|
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 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 screenWidth = LocalConfiguration.current.screenWidthDp
|
||||||
val screenHeight = LocalConfiguration.current.screenHeightDp
|
val screenHeight = LocalConfiguration.current.screenHeightDp
|
||||||
@@ -598,13 +662,14 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
LaunchedEffect(isConnected, isDemoMode) {
|
LaunchedEffect(isConnected, isDemoMode) {
|
||||||
Log.d("ImuDbg", "LaunchedEffect start isConnected=$isConnected isDemoMode=$isDemoMode")
|
Log.d("ImuDbg", "LaunchedEffect start isConnected=$isConnected isDemoMode=$isDemoMode")
|
||||||
if (isDemoMode) {
|
if (isDemoMode) {
|
||||||
currentPosture = com.example.medilightv2android.managers.Posture.SUPINE
|
currentPosture = com.example.medilightv2android.managers.Posture.LYING
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
bleManager.imuCollector.onComplete = { samples ->
|
bleManager.imuCollector.onComplete = { samples ->
|
||||||
val r = com.example.medilightv2android.managers.ImuPostureClassifier.classify(samples)
|
val r = com.example.medilightv2android.managers.ImuPostureClassifier.classify(samples)
|
||||||
if (r.posture != com.example.medilightv2android.managers.Posture.UNKNOWN) {
|
if (r.posture != com.example.medilightv2android.managers.Posture.UNKNOWN) {
|
||||||
currentPosture = r.posture
|
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}")
|
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 자세 분류 결과 표시.
|
// Posture icon (Phase 1) — flowchart-based 자세 분류 결과 표시.
|
||||||
// SUPINE/SITTING/STANDING/UNKNOWN 에 따라 아이콘+색 변경.
|
// SUPINE/SITTING/STANDING/UNKNOWN 에 따라 아이콘+색 변경.
|
||||||
val postureIcon = when (currentPosture) {
|
val postureIcon = when (currentPosture) {
|
||||||
com.example.medilightv2android.managers.Posture.SUPINE -> Icons.Default.Hotel
|
com.example.medilightv2android.managers.Posture.LYING -> Icons.Default.Hotel
|
||||||
com.example.medilightv2android.managers.Posture.SITTING -> Icons.Default.AirlineSeatReclineNormal
|
com.example.medilightv2android.managers.Posture.NON_LYING -> Icons.Default.AccessibilityNew
|
||||||
com.example.medilightv2android.managers.Posture.STANDING -> Icons.Default.AccessibilityNew
|
|
||||||
com.example.medilightv2android.managers.Posture.UNKNOWN -> Icons.Default.HelpOutline
|
com.example.medilightv2android.managers.Posture.UNKNOWN -> Icons.Default.HelpOutline
|
||||||
}
|
}
|
||||||
val postureTint = when (currentPosture) {
|
val postureTint = when (currentPosture) {
|
||||||
com.example.medilightv2android.managers.Posture.SUPINE -> Color(0xFF2196F3)
|
com.example.medilightv2android.managers.Posture.LYING -> Color(0xFF2196F3)
|
||||||
com.example.medilightv2android.managers.Posture.SITTING -> Color(0xFFFF9800)
|
com.example.medilightv2android.managers.Posture.NON_LYING -> Color(0xFF4CAF50)
|
||||||
com.example.medilightv2android.managers.Posture.STANDING -> Color(0xFF4CAF50)
|
|
||||||
com.example.medilightv2android.managers.Posture.UNKNOWN -> Color.Gray.copy(alpha = 0.5f)
|
com.example.medilightv2android.managers.Posture.UNKNOWN -> Color.Gray.copy(alpha = 0.5f)
|
||||||
}
|
}
|
||||||
val postureLabel = when (currentPosture) {
|
val postureLabel = when (currentPosture) {
|
||||||
com.example.medilightv2android.managers.Posture.SUPINE -> "Lying"
|
com.example.medilightv2android.managers.Posture.LYING -> "Lying"
|
||||||
com.example.medilightv2android.managers.Posture.SITTING -> "Sitting"
|
com.example.medilightv2android.managers.Posture.NON_LYING -> "Upright"
|
||||||
com.example.medilightv2android.managers.Posture.STANDING -> "Standing"
|
|
||||||
com.example.medilightv2android.managers.Posture.UNKNOWN -> "—"
|
com.example.medilightv2android.managers.Posture.UNKNOWN -> "—"
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -1713,6 +1775,25 @@ fun PiezoMonitoringView(appState: AppState) {
|
|||||||
steps = 8,
|
steps = 8,
|
||||||
colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)
|
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))
|
HorizontalDivider(modifier = Modifier.padding(vertical = dividerPadding))
|
||||||
|
|||||||
+48
@@ -79,6 +79,7 @@ fun PiezoPersonalizationView(appState: AppState) {
|
|||||||
MaxVolumeCard(appState)
|
MaxVolumeCard(appState)
|
||||||
CatheterThresholdCard(appState)
|
CatheterThresholdCard(appState)
|
||||||
AutoScanIntervalCard(appState)
|
AutoScanIntervalCard(appState)
|
||||||
|
WakeupAlarmCard()
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
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))
|
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)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user