feat(demo): V2 default + 그래프/문구 일치 + Slider lock (스크롤 충돌 방지)

1. V2 alignment 를 모든 진입점 default 로
   - GreenZoneConstants.alignmentAlgo = V2
   - PlacementGuideView LaunchedEffect 의 clinical 한정 분기 제거 → 항상 V2
   - 사용자 경험 통일 (4-stage Method D)

2. 그래프 ↔ 문구 일치 — 잔여 모순 fix
   - CH3_STABILIZE 의 STOP action: 이전엔 isLocked=true + GREEN LED + checkmark
     아이콘 → '완료처럼 보이는데 알고리즘은 stabilizing 중' 모순. 변경: phase가
     LR_BALANCE 일 때만 finalStop → 진짜 GREEN. CH3_STABILIZE STOP 은 아이콘 X.
   - PROBE_LR action: 이전엔 arrow.left (한쪽 화살표). msg 의 ↔ 와 불일치.
     변경: 아이콘 없음 (msg 의 ↔ 만으로 양방향 의미 전달).

3. LockableSlider 신규 — 스크롤 충돌 차단
   - 잠금 아이콘 1회 탭 → 활성화 → drag 가능 → 다시 탭하면 잠금
   - 잠김 상태에서는 slider disabled → 위아래 스크롤 손가락 끌림에 영향 X
   - 적용: PiezoMonitoring 의 dev panel 17 sliders +
          ReminderSettings 의 2 sliders (배뇨 알림 기간, 기상 알람 기준 시간)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-26 16:45:49 +09:00
parent 618267d8c0
commit 22ef6576f8
5 changed files with 114 additions and 29 deletions
@@ -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
@@ -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<Float> = 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)
)
}
}
@@ -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))
@@ -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
@@ -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()