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 54a6cf5..c7f34c0 100644 --- a/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt +++ b/app/src/main/java/com/example/medilightv2android/managers/GreenZoneConstants.kt @@ -26,7 +26,7 @@ object GreenZoneConstants { @Volatile var detectionMethod: DetectionMethod = DetectionMethod.METHOD_C @Volatile var bvMethod: BvMethod = BvMethod.V41 @Volatile var placementGuideMode: PlacementGuideMode = PlacementGuideMode.SIMPLE - @Volatile var alignmentAlgo: AlignmentAlgo = AlignmentAlgo.V1 // dev 토글 — default V1 (legacy) + @Volatile var alignmentAlgo: AlignmentAlgo = AlignmentAlgo.V2 // default V2 (4-stage Method D). V1 은 dev 토글로 fallback. /** Posture pitch 임계값 (deg). |pitch| > 이 값 → LYING, ≤ 이 값 → NON_LYING. dev 모드에서 조정 가능. */ @Volatile var pitchLyingThreshold: Float = 25f diff --git a/app/src/main/java/com/example/medilightv2android/ui/components/LockableSlider.kt b/app/src/main/java/com/example/medilightv2android/ui/components/LockableSlider.kt new file mode 100644 index 0000000..f552806 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/ui/components/LockableSlider.kt @@ -0,0 +1,77 @@ +/* + * LockableSlider — 표준 Material3 Slider 를 잠금 아이콘으로 감싼 wrapper. + * + * 사용 의도: + * 설정 패널 등 ScrollView 안의 슬라이더가 위아래 스크롤 시 손가락 끌림에 의해 + * 값이 의도치 않게 바뀌는 문제 차단. 사용자가 잠금 아이콘을 1회 탭하여 활성화한 + * 뒤에만 슬라이더가 반응. + * + * 동작: + * - 기본 상태: 잠김 (회색 lock 아이콘 + slider disabled) + * - 잠금 아이콘 tap → 활성화 (녹색 lock_open 아이콘 + slider enabled) + * - 다시 tap → 재잠금 + * - 잠금 상태에서는 slider drag 무시 → 위아래 스크롤 안전 + * + * UX 톤: + * "1회 터치 후 조정" 패턴 — 의도된 조작만 허용. + */ +package com.example.medilightv2android.ui.components + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +fun LockableSlider( + value: Float, + onValueChange: (Float) -> Unit, + modifier: Modifier = Modifier, + valueRange: ClosedFloatingPointRange = 0f..1f, + steps: Int = 0, + onValueChangeFinished: (() -> Unit)? = null, + colors: SliderColors = SliderDefaults.colors(), +) { + var unlocked by remember { mutableStateOf(false) } + Row(verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + IconButton( + onClick = { unlocked = !unlocked }, + modifier = Modifier.size(32.dp) + ) { + Icon( + if (unlocked) Icons.Default.LockOpen else Icons.Default.Lock, + contentDescription = if (unlocked) "잠금 해제됨 — 다시 탭하여 잠금" else "잠김 — 탭하여 해제 후 조정", + tint = if (unlocked) Color(0xFF4CAF50) else Color.Gray.copy(alpha = 0.5f), + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(4.dp)) + Slider( + value = value, + onValueChange = if (unlocked) onValueChange else { _ -> }, + onValueChangeFinished = onValueChangeFinished, + valueRange = valueRange, + steps = steps, + colors = colors, + enabled = unlocked, + modifier = Modifier.weight(1f) + ) + } +} 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 460d007..e997a7e 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 @@ -55,6 +55,7 @@ import com.example.medilightv2android.models.UrgencyLevel import com.example.medilightv2android.services.ScanLogStore import com.example.medilightv2android.ui.components.BladdyRiveView import com.example.medilightv2android.ui.components.DonutChartView +import com.example.medilightv2android.ui.components.LockableSlider import com.example.medilightv2android.ui.components.ScanLogDialog import com.example.medilightv2android.R import com.example.medilightv2android.ui.theme.* @@ -1651,7 +1652,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(modifier = Modifier.weight(1f)) Text("${appState.piezoSettings.maxVolume} mL", fontSize = labelSize, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider( + LockableSlider( value = appState.piezoSettings.maxVolume.toFloat(), onValueChange = { appState.piezoSettings = appState.piezoSettings.copy(maxVolume = it.toInt()) }, valueRange = 200f..800f, @@ -1696,7 +1697,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(modifier = Modifier.weight(1f)) Text("Level ${appState.bladderLevel.catheterizationThreshold}", fontSize = labelSize, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider( + LockableSlider( value = appState.bladderLevel.catheterizationThreshold.toFloat(), onValueChange = { appState.updateCatheterizationThreshold(it.toInt()) }, valueRange = 1f..10f, @@ -1743,7 +1744,7 @@ fun PiezoMonitoringView(appState: AppState) { } } } - Slider( + LockableSlider( value = valueClamped.toFloat(), onValueChange = { val snapped = ((it * 2).roundToInt() / 2.0).coerceIn(0.5, 10.0) @@ -1779,7 +1780,7 @@ fun PiezoMonitoringView(appState: AppState) { ) } if (!otsuEnabled) { - Slider( + LockableSlider( value = currentThreshold, onValueChange = { currentThreshold = it; GreenZoneConstants.lowEchoAmp = it }, valueRange = 800f..2000f, @@ -1888,7 +1889,7 @@ fun PiezoMonitoringView(appState: AppState) { Text("${postMaxVal.toInt()} / 100", fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary) Spacer(modifier = Modifier.weight(1f)) } - Slider( + LockableSlider( value = postMaxVal, onValueChange = { postMaxVal = it; GreenZoneConstants.postMaxIdx = it.toInt() }, valueRange = 50f..95f, @@ -1907,7 +1908,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(modifier = Modifier.weight(1f)) Text("|pitch|≤${pitchThrVal.toInt()}°→Lying", fontSize = 11.sp, color = MlSecondaryText) } - Slider( + LockableSlider( value = pitchThrVal, onValueChange = { pitchThrVal = it; GreenZoneConstants.pitchLyingThreshold = it com.example.medilightv2android.services.PostureTuningPrefs.saveAll(context) }, @@ -1956,7 +1957,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(modifier = Modifier.weight(1f)) Text("이상 lying 후 일어나면 알람", fontSize = 10.sp, color = MlSecondaryText) } - Slider( + LockableSlider( value = lyingDurVal, onValueChange = { lyingDurVal = it @@ -1981,7 +1982,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(modifier = Modifier.weight(1f)) Text("자세 갱신 주기", fontSize = 10.sp, color = MlSecondaryText) } - Slider( + LockableSlider( value = pollIntervalVal, onValueChange = { pollIntervalVal = it @@ -2004,7 +2005,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(Modifier.weight(1f)) Text("${walkPitchVal.toInt()}°", fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider(value = walkPitchVal, onValueChange = { + LockableSlider(value = walkPitchVal, onValueChange = { walkPitchVal = it; GreenZoneConstants.walkingMinPitchDeg = it com.example.medilightv2android.services.PostureTuningPrefs.saveAll(context) }, valueRange = 20f..70f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) @@ -2015,7 +2016,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(Modifier.weight(1f)) Text("%.1f".format(walkStartWmean), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider(value = walkStartWmean, onValueChange = { + LockableSlider(value = walkStartWmean, onValueChange = { walkStartWmean = it; GreenZoneConstants.walkingStartWmean = it com.example.medilightv2android.services.PostureTuningPrefs.saveAll(context) }, valueRange = 1f..30f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) @@ -2026,7 +2027,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(Modifier.weight(1f)) Text("%.1f".format(walkStartWstd), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider(value = walkStartWstd, onValueChange = { + LockableSlider(value = walkStartWstd, onValueChange = { walkStartWstd = it; GreenZoneConstants.walkingStartWstd = it com.example.medilightv2android.services.PostureTuningPrefs.saveAll(context) }, valueRange = 0.5f..10f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) @@ -2037,7 +2038,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(Modifier.weight(1f)) Text("%.1f".format(walkKeepWmean), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider(value = walkKeepWmean, onValueChange = { + LockableSlider(value = walkKeepWmean, onValueChange = { walkKeepWmean = it; GreenZoneConstants.walkingKeepWmean = it com.example.medilightv2android.services.PostureTuningPrefs.saveAll(context) }, valueRange = 0.5f..30f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) @@ -2049,7 +2050,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(Modifier.weight(1f)) Text("%.1f초".format(walkStartHold), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider(value = walkStartHold, onValueChange = { + LockableSlider(value = walkStartHold, onValueChange = { walkStartHold = it; GreenZoneConstants.walkingStartHoldMs = (it * 1000).toLong() com.example.medilightv2android.services.PostureTuningPrefs.saveAll(context) }, valueRange = 0.5f..10f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) @@ -2060,7 +2061,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(Modifier.weight(1f)) Text("%.1f초".format(walkKeepHold), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider(value = walkKeepHold, onValueChange = { + LockableSlider(value = walkKeepHold, onValueChange = { walkKeepHold = it; GreenZoneConstants.walkingKeepHoldMs = (it * 1000).toLong() com.example.medilightv2android.services.PostureTuningPrefs.saveAll(context) }, valueRange = 0.5f..10f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) @@ -2071,7 +2072,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(Modifier.weight(1f)) Text("%.1f초".format(walkWindow), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider(value = walkWindow, onValueChange = { + LockableSlider(value = walkWindow, onValueChange = { walkWindow = it; GreenZoneConstants.walkingWindowMs = (it * 1000).toLong() com.example.medilightv2android.services.PostureTuningPrefs.saveAll(context) }, valueRange = 0.5f..5f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) @@ -2087,7 +2088,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(Modifier.weight(1f)) Text("%.2fg".format(accMagMin), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider(value = accMagMin, onValueChange = { + LockableSlider(value = accMagMin, onValueChange = { accMagMin = it; GreenZoneConstants.postureAccMagMin = it com.example.medilightv2android.services.PostureTuningPrefs.saveAll(context) }, valueRange = 0.3f..1.0f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) @@ -2098,7 +2099,7 @@ fun PiezoMonitoringView(appState: AppState) { Spacer(Modifier.weight(1f)) Text("%.2fg".format(accMagMax), fontSize = 12.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider(value = accMagMax, onValueChange = { + LockableSlider(value = accMagMax, onValueChange = { accMagMax = it; GreenZoneConstants.postureAccMagMax = it com.example.medilightv2android.services.PostureTuningPrefs.saveAll(context) }, valueRange = 1.0f..3.0f, colors = SliderDefaults.colors(thumbColor = MlPrimary, activeTrackColor = MlPrimary)) diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 089b4d7..8a3aaea 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -121,16 +121,15 @@ fun PlacementGuideView(appState: AppState) { var sweepLastDetected by remember { mutableIntStateOf(0) } var sweepEverSawAll by remember { mutableStateOf(false) } - // 화면 진입 시 전체 상태 리셋. clinical alignment session (placementFromMonitoring=false + - // 활성 ALIGNMENT 세션) 이면 V2, 그 외 (PiezoMonitoring 에서 진입 / 위저드 진입 / 잔여 세션) 는 항상 V1. + // 화면 진입 시 전체 상태 리셋. V2 (4-stage Method D) 를 default 로 사용 — 모든 진입점에서. + // (이전: clinical alignment 만 V2, 그 외 V1 → 사용자 경험 분산. 통일.) LaunchedEffect(Unit) { val sess = com.example.medilightv2android.services.ClinicalSessionStore.currentSession val isClinicalAlign = !appState.placementFromMonitoring && sess != null && sess.mode == com.example.medilightv2android.models.SessionMode.ALIGNMENT com.example.medilightv2android.managers.GreenZoneConstants.alignmentAlgo = - if (isClinicalAlign) com.example.medilightv2android.managers.AlignmentAlgo.V2 - else com.example.medilightv2android.managers.AlignmentAlgo.V1 + com.example.medilightv2android.managers.AlignmentAlgo.V2 v2Aligner.reset() // Clinical alignment session 진입 시 — 자동으로 scan 시작 (Step 0 baseline 캡쳐). // waitingForStart 는 true 유지 — 사용자는 여전히 Start 눌러 알고리즘 시작 결정. @@ -887,18 +886,25 @@ fun PlacementGuideView(appState: AppState) { placementScore = 5 } else { directionHint = st.msg + // STOP 액션은 두 경우 — CH3_STABILIZE 의 "위치 유지" (still in progress) vs + // LR_BALANCE 의 "정렬 완료" (truly done). 후자만 finalStop 으로 GREEN lock. + val finalStop = st.action == com.example.medilightv2android.managers.AlignAction.STOP && + v2Aligner.phase == com.example.medilightv2android.managers.AlignGuide4Stage.Phase4.LR_BALANCE directionIcon = when (st.action) { com.example.medilightv2android.managers.AlignAction.MOVE_UP -> "arrow.up" com.example.medilightv2android.managers.AlignAction.MOVE_DOWN -> "arrow.down" com.example.medilightv2android.managers.AlignAction.MOVE_LEFT -> "arrow.left" com.example.medilightv2android.managers.AlignAction.MOVE_RIGHT -> "arrow.right" - com.example.medilightv2android.managers.AlignAction.PROBE_LR -> "arrow.left" - com.example.medilightv2android.managers.AlignAction.STOP -> "checkmark.circle" + com.example.medilightv2android.managers.AlignAction.PROBE_LR -> "" // ↔ — 한쪽 화살표 안 어울림. msg 의 ↔ 만 표시 + com.example.medilightv2android.managers.AlignAction.STOP -> if (finalStop) "checkmark.circle" else "" null -> "" } - placementScore = if (st.action == - com.example.medilightv2android.managers.AlignAction.STOP) 100 else 40 - if (st.action == com.example.medilightv2android.managers.AlignAction.STOP) { + placementScore = when { + finalStop -> 100 + st.action == com.example.medilightv2android.managers.AlignAction.STOP -> 60 // CH3_STABILIZE 유지 중 + else -> 40 + } + if (finalStop) { if (!isLocked) greenLockedAtMs = System.currentTimeMillis() isLocked = true placementPhase = PlacementPhase.GREEN diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt index 5f65a60..e7dfbe9 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/reminder/ReminderSettingsView.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.medilightv2android.services.ReminderService +import com.example.medilightv2android.ui.components.LockableSlider import com.example.medilightv2android.ui.theme.* @OptIn(ExperimentalMaterial3Api::class) @@ -137,7 +138,7 @@ fun ReminderSettingsView(onBack: () -> Unit) { Text("시작", fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp)) Text("${startHour}시", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider( + LockableSlider( value = startHour.toFloat(), onValueChange = { startHour = it.toInt() @@ -159,7 +160,7 @@ fun ReminderSettingsView(onBack: () -> Unit) { Text("종료", fontSize = 13.sp, color = MlSecondaryText, modifier = Modifier.width(40.dp)) Text("${endHour}시", fontSize = 18.sp, fontWeight = FontWeight.Bold, color = MlPrimary) } - Slider( + LockableSlider( value = endHour.toFloat(), onValueChange = { endHour = it.toInt()