diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index 0aa2e6b..421fa9f 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -13,11 +13,17 @@ * 조작자가 freq·cycle 조합을 손으로 고르면 반드시 빠뜨리거나 잘못 기록한다 — 눈으로 * 보고 답할 수 있는 것은 복부 두께이고, 조합은 거기서 따라 나온다([AbdomenThickness]). * - * ## 화면은 두 덩어리다 - * 구분선 위는 **프로토콜**(환자명 → 정렬 → 자세 → 충만도 → 반복 → 두께 → 측정 시작), - * 아래는 **수동 확인**(프로브 파라미터 주입 · 1회/연속 BV 측정)이다. 아래쪽 값은 - * 저장도 업로드도 되지 않는다 — 한 흐름으로 붙여 두면 조작자가 수동 측정을 프로토콜의 - * 한 단계로 착각한다. + * ## 화면은 "늘 보이는 것"과 "접힌 것"이다 (2026-09-29 임상 참관 뒤) + * 측정하는 동안 실제로 보는 것은 넷이다 — **기울기 · 채움 정도 · 채널 접촉 · 시작/중지**. + * 이 넷이 스크롤 없이 한 화면에 있어야 한다. 그래서 위에는 그 흐름만 둔다: + * + * 연결 → labdb → 환자명 → 자세 → 기울기 → 채움 정도 → 측정 시작/중지 → 채널 접촉(측정 중) + * + * 세션에 한 번 만지거나 안 만지는 것은 아래 접는 구간([Collapsible])이다 — 측정 조건· + * 부착 위치(정렬·반복·두께), 회차 상세(용적·파형), labdb 업로드·기록, 수동 확인(프로브 + * 파라미터 · 1회/연속 BV). 접힌 머리줄에 요약을 적어 미정렬·미업로드는 펴 보지 않아도 + * 보인다. 수동 확인 값은 저장도 업로드도 되지 않는다 — 한 흐름으로 붙여 두면 조작자가 + * 수동 측정을 프로토콜의 한 단계로 착각하므로 제목에 못 박고 접어 둔다. */ package com.medithings.vesiscan.ui.views.clinical @@ -29,6 +35,8 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -481,15 +489,35 @@ fun HospitalModeView(appState: AppState) { } Spacer(Modifier.height(8.dp)) - // 프로브 기울기 — 상시 (2026-09-28 요청). Sitting 을 고르면 요구 범위가 함께 뜬다. - ImuTiltCard( - tiltDeg = liveTilt, - posture = posture, - connected = isConnected, - running = running, - ) + // ── 화면 구성 (2026-09-29 임상 참관 뒤) ───────────────────────── + // 측정하는 동안 실제로 보는 것은 넷이다 — 기울기 · 채움 정도 · 채널 접촉 · + // 시작/중지. 이 넷이 스크롤 없이 한 화면에 있어야 한다. 그래서 위에는 그 흐름 + // (연결 → labdb → 환자 → 자세 → 기울기 → 채움 → 시작 → 접촉)만 두고, 세션에 + // 한 번 만지거나 안 만지는 것(정렬·반복·두께, 회차 상세, 업로드, 수동 도구)은 + // 아래 [Collapsible] 로 내렸다. 접힌 머리줄에 요약을 적어 펴 보지 않아도 + // 상태(미정렬·미업로드)가 보이게 한다. + + // 2. labdb 등록 상태. **측정 전에** 보여야 한다 — 미등록이면 업로드 시도조차 + // 일어나지 않는데(ClinicalSessionStore·HospitalLabdbUploader 가 둘 다 + // isRegistered 뒤에 있다), 20 cycle × 조합을 받은 뒤에 알면 방광을 비운 뒤라 + // 다시 잴 수 없다. + // 측정 중에는 labdb·환자명·자세 셋 다 손댈 수 없다. 한 줄로 접어 기울기·채움· + // 진행·중지·접촉이 스크롤 없이 한 화면에 들어오게 한다 — 이 화면을 다시 짠 이유다. + if (running) { + Text( + "환자 ${patient.ifBlank { "(이름 없음)" }} · ${posture.label} · labdb " + + (if (LabdbCredentials.isRegistered) "연결됨" else "미등록"), + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlNeutral, + modifier = Modifier.fillMaxWidth() + .background(Color.White, RoundedCornerShape(10.dp)) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) + Spacer(Modifier.height(12.dp)) + } else { + LabdbStatusCard(enabled = true) Spacer(Modifier.height(12.dp)) + // 3. 환자명 OutlinedTextField( value = patient, onValueChange = { @@ -510,37 +538,24 @@ fun HospitalModeView(appState: AppState) { ) Spacer(Modifier.height(12.dp)) - // labdb 등록 상태. **측정 전에** 보여야 한다 — 미등록이면 업로드 시도조차 - // 일어나지 않는데(ClinicalSessionStore·HospitalLabdbUploader 가 둘 다 - // isRegistered 뒤에 있다), 종전에는 업로드 카드가 isRegistered 로 감싸져 - // 있어 미등록일 때 화면에 아무것도 없었다. 20 cycle × 조합을 받은 뒤에 - // 알면 방광을 비운 뒤라 다시 잴 수 없다. - LabdbStatusCard(enabled = !running) - Spacer(Modifier.height(12.dp)) - - // 자세는 **정렬보다 위**에 둔다(2026-09-28). 정렬은 자세에 매인 일이다 — BV 의 - // supine 분기와 Sitting 의 35°~55° 요구 범위가 이 값을 본다. 정렬 카드가 위에 - // 있던 동안은 자세를 안 고른 채(기본 Supine) 정렬에 들어가 띠 없이 붙이고 왔다. + // 4. 자세 — 기울기 카드·정렬보다 위. 카드의 요구 범위(Sitting 35°~55°)와 정렬의 + // BV supine 분기가 이 값을 본다. Label("자세") - ChipRow(ClinicalPosture.entries, posture, { posture = it }, !running, + ChipRow(ClinicalPosture.entries, posture, { posture = it }, true, progressOf = { postureProgress(it) }) { it.label } - PostureTilt.rangeFor(posture)?.let { r -> - Hint("${posture.label} 은 프로브 기울기 ${r.start.toInt()}°~${r.endInclusive.toInt()}° 에서 잰다 — 위 카드로 맞춘다") - } Spacer(Modifier.height(12.dp)) + } - // 연결 여부로 막지 않는다(2026-09-28). 종전에는 `isConnected` 도 조건이라 - // 프로브가 안 붙은 채 [정렬 시작] 을 누르면 아무 일도 없었다 — 글자색이 - // 항상 파랑이라 비활성인 줄도 모른다. 정렬 화면에 [기기 연결] 이 있고 측정 - // 버튼은 거기서 연결을 본다. 들어가서 붙이면 된다. - AnchorCard( - anchorCm = appState.anchorCm, - basis = appState.anchorBasis, - enabled = !running, - onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN }, + // 5. 프로브 기울기 — 상시 (2026-09-28 요청). Sitting 이면 카드 안에 요구 범위가 뜬다. + ImuTiltCard( + tiltDeg = liveTilt, + posture = posture, + connected = isConnected, + running = running, ) Spacer(Modifier.height(12.dp)) + // 6. 방광 채움 정도 Label("방광 채움 정도 — 간호사가 채워 둔 값") // 방광 단계는 **지금 고른 자세 안에서**의 진행이다. 자세를 바꾸면 표시도 바뀐다. ChipRow(BladderFill.entries, fill, { fill = it }, !running, @@ -550,51 +565,9 @@ fun HospitalModeView(appState: AppState) { if (patient.isBlank()) "환자명을 넣으면 오늘 이 환자의 진행 상황이 표시된다." else "회색 ✓ 완료 · 주황 · 일부(다시 눌러 마저 측정)" ) - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = repeatsText, - onValueChange = { if (!running) repeatsText = it.filter { c -> c.isDigit() }.take(3) }, - label = { Text("조합당 반복 횟수") }, - singleLine = true, - enabled = !running, - supportingText = { - Text("${thickness.combos.size}조합 × ${repeats}회 = " + - "총 ${thickness.combos.size * repeats}회 측정") - }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(12.dp)) - - // ── 측정할 조합 = 복부 두께 ───────────────────────────────────── - // 조작자에게 freq·cycle 조합을 고르게 하지 않는다. 눈으로 보고 답할 수 있는 - // 것은 복부 두께이고, 거기서 조합이 따라 나온다. - Label("측정할 조합 — 복부 두께로 정합니다") - ChipRow(AbdomenThickness.entries, thickness, { thickness = it }, !running) { - "복부 두께 ${it.label}" - } - Hint("${thickness.comboLabel} → 총 ${thickness.combos.size * repeats}회") Spacer(Modifier.height(16.dp)) - // 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상 - // 조건으로 돌게 되므로 눈에 띄어야 한다. - restoreNote?.let { note -> - val failed = guardRestoreFailed(note) - Text( - note, - fontSize = 12.sp, - color = if (failed) MlCritical else MlSecondaryText, - modifier = Modifier - .fillMaxWidth() - .background( - (if (failed) MlCritical else MlSuccess).copy(alpha = 0.10f), - RoundedCornerShape(8.dp), - ) - .padding(10.dp), - ) - Spacer(Modifier.height(12.dp)) - } - + // 7. 측정 시작 — 돌 때는 진행 패널 + 중지 if (running) { val (f, c) = thickness.combos.getOrElse(comboIdx) { thickness.combos.first() } ProgressPanel( @@ -629,123 +602,172 @@ fun HospitalModeView(appState: AppState) { else if (patient.isBlank()) Hint("환자명을 입력해 주세요.") } - // ── 프로토콜 실행의 BV ────────────────────────────────────────── - // 돌 때는 직전 회차, 끝난 뒤에는 마지막 회차. 끝나고도 남겨 둔다 — 방광을 - // 비우기 전에 "방금 받은 값이 말이 되나"를 한 번 더 볼 수 있어야 한다. - if (running || runBv != null) { - Spacer(Modifier.height(12.dp)) - Label("방광 용적") - Spacer(Modifier.height(6.dp)) - // 캡션에 **조건을 적는다.** 프로토콜은 조합마다 주파수가 바뀌는데 - // 정렬·수동 확인은 2.3MHz c3 고정이라, 조건을 안 적으면 세 숫자를 - // 같은 것으로 놓고 비교한다. - val shown = thickness.combos.getOrElse(comboIdx) { thickness.combos.first() } - com.medithings.vesiscan.ui.components.BvPanel( - runBv, - caption = (if (running) "직전 회차" else "마지막 회차") + - " · ${shown.first.label}MHz c${shown.second.cycles} · 1 cycle", - compact = true, + // 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상 + // 조건으로 돌게 되므로 눈에 띄어야 한다. 버튼 **아래**에 둔다 — 위에 두면 + // 실행이 끝날 때마다 버튼 자리가 밀린다. + restoreNote?.let { note -> + val failed = guardRestoreFailed(note) + Spacer(Modifier.height(10.dp)) + Text( + note, + fontSize = 12.sp, + color = if (failed) MlCritical else MlSecondaryText, + modifier = Modifier + .fillMaxWidth() + .background( + (if (failed) MlCritical else MlSuccess).copy(alpha = 0.10f), + RoundedCornerShape(8.dp), + ) + .padding(10.dp), ) } - // 접촉·파형은 돌 때만 띄운다. 측정하는 사람이 값이 제대로 잡히는지 **그 - // 자리에서** 봐야 한다 — 20회를 다 돌린 뒤 파일을 열어 보면 잘못 붙은 것을 - // 알았을 때 이미 그 단계를 다시 잴 수 없다(방광을 비웠거나 자세가 바뀌었다). - // - // 셀을 작게 잡는다(140dp). 이 자리의 주인공은 진행 상황과 중지 버튼이라 - // 크게 잡으면 중지 버튼이 화면 밖으로 밀린다. + // 8. 채널 접촉 — 돌 때만. 판정이 떴을 때만 보이게 하면 "정상"과 "아직 판정 + // 없음"을 화면에서 가를 수 없다. 늘 띄우고 색으로 말한다(회색 확인 중 · 초록 + // 정상 · 빨강 확인 필요). 잘못 붙은 것은 **그 자리에서** 알아야 한다 — 20회를 + // 다 돌린 뒤에는 방광을 비웠거나 자세가 바뀌어 그 단계를 다시 잴 수 없다. if (running) { Spacer(Modifier.height(12.dp)) Label("채널 접촉") Spacer(Modifier.height(6.dp)) ChannelDetachPanel(detachSnapshot) + } - Spacer(Modifier.height(12.dp)) - Label("채널 파형") - Spacer(Modifier.height(6.dp)) - SixChannelWaveformGrid( - channels = liveChannels, - walls = runBv?.markers ?: emptyList(), - modifier = Modifier.fillMaxWidth(), - cellHeight = 140.dp, - compact = true, + Spacer(Modifier.height(20.dp)) + + // ── 접는 구간 ─────────────────────────────────────────────────── + + // A. 측정 조건 · 부착 위치 — 환자마다 한 번. 머리줄에 정렬 상태를 적어 미정렬이 + // 접힌 채로도 보이게 한다(기록에 부착 위치가 남지 않는 상태를 놓치면 안 된다). + val anchorSummary = appState.anchorCm?.let { cm -> + if (appState.anchorBasis?.isAlgorithm == true) "부착 위치 ${cm}cm 확정" + else "부착 위치 ${cm}cm · ${appState.anchorBasis?.label ?: "근거 불명"}" + } ?: "부착 위치 미정렬" + Collapsible( + title = "측정 조건 · 부착 위치", + summary = "$anchorSummary · ${repeats}회 × ${thickness.combos.size}조합 · 복부 ${thickness.label}", + warn = appState.anchorCm == null, + ) { + // 연결 여부로 막지 않는다(2026-09-28). 정렬 화면에 [기기 연결] 이 있고 + // 측정 버튼은 거기서 연결을 본다. 들어가서 붙이면 된다. + AnchorCard( + anchorCm = appState.anchorCm, + basis = appState.anchorBasis, + enabled = !running, + onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN }, ) - if (liveChannels.isEmpty()) { - Hint("첫 회차를 받으면 파형이 나타납니다.") + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = repeatsText, + onValueChange = { if (!running) repeatsText = it.filter { c -> c.isDigit() }.take(3) }, + label = { Text("조합당 반복 횟수") }, + singleLine = true, + enabled = !running, + supportingText = { + Text("${thickness.combos.size}조합 × ${repeats}회 = " + + "총 ${thickness.combos.size * repeats}회 측정") + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + + // 측정할 조합 = 복부 두께. 조작자에게 freq·cycle 조합을 고르게 하지 않는다. + // 눈으로 보고 답할 수 있는 것은 복부 두께이고, 거기서 조합이 따라 나온다. + Label("측정할 조합 — 복부 두께로 정합니다") + ChipRow(AbdomenThickness.entries, thickness, { thickness = it }, !running) { + "복부 두께 ${it.label}" } - Spacer(Modifier.height(16.dp)) - } else { - // ── labdb 업로드 ──────────────────────────────────────────── - // 조합이 끝날 때마다 자동으로 올라간다. 인터넷이 끊겨 있었으면 여기 - // 남아 있다가 이 버튼으로 한꺼번에 올린다 — 병원 무선망이 불안정한 - // 곳이 많아 "나중에 올리기"가 예외가 아니라 정상 경로다. - // **lastRunDir 에 의존하지 않는다.** 그건 이번 실행의 폴더이고 `remember` - // 라서 앱을 다시 켜면 null 이 된다 — 그러면 카드가 사라져 지난 측정을 올릴 - // 길이 없어진다(2026-09-10 병원 39건). - // - // isRegistered 로도 감싸지 않는다. 미등록일 때도 몇 건이 못 올라갔는지는 - // 보여야 한다 — 승인 뒤 한꺼번에 보내야 하므로. - if (pendingUploads > 0 || lastRunDir != null) { - Spacer(Modifier.height(12.dp)) - Box( - Modifier.fillMaxWidth() - .background( - if (pendingUploads > 0) Color(0xFFFFF3E0) else MlPrimary.copy(alpha = 0.07f), - RoundedCornerShape(12.dp), - ) - .padding(12.dp) - ) { - Column { - Text( - if (pendingUploads > 0) - "labdb 미업로드 ${pendingUploads}건 (지난 측정 포함)" - else "labdb 업로드 완료", - fontSize = 14.sp, fontWeight = FontWeight.Bold, - color = if (pendingUploads > 0) Color(0xFFE65100) else MlSuccess, - ) - (HospitalLabdbUploader.uploadingName.value ?: uploadMsg)?.let { - Spacer(Modifier.height(4.dp)) - Text(it, fontSize = 12.sp, color = MlSecondaryText) - } - if (pendingUploads > 0) { - Spacer(Modifier.height(8.dp)) - Button( - onClick = { - uploadBusy = true - uploadScope.launch { - // 전 폴더를 올린다 — 지난 측정까지. - uploadMsg = HospitalLabdbUploader.uploadAllPending() - pendingUploads = - HospitalLabdbUploader.pendingAll().size - uploadBusy = false - } - }, - // 미등록 상태로 누르면 전부 실패 마커가 쌓이고, 그 - // 마커 때문에 나중에 성공 여부를 헷갈리게 된다. - enabled = !uploadBusy && LabdbCredentials.isRegistered, - modifier = Modifier.fillMaxWidth().height(46.dp), - shape = RoundedCornerShape(12.dp), - ) { - Text( - if (uploadBusy) "업로드 중…" else "미업로드 전부 올리기", - fontWeight = FontWeight.Bold, - ) - } - Spacer(Modifier.height(4.dp)) - Text( - "요청 사이를 띄워 보냅니다 — ${pendingUploads}건이면 약 " + - "${(pendingUploads - 1) * 8 / 60 + 1}분 걸립니다.", - fontSize = 11.sp, color = MlSecondaryText, - ) - } + Hint("${thickness.comboLabel} → 총 ${thickness.combos.size * repeats}회") + } + Spacer(Modifier.height(10.dp)) + + // B. 회차 상세 — 돌 때는 직전 회차, 끝난 뒤에는 마지막 회차. 끝나고도 남겨 둔다 — + // 방광을 비우기 전에 "방금 받은 값이 말이 되나"를 한 번 더 볼 수 있어야 한다. + if (running || runBv != null) { + // 캡션에 **조건을 적는다.** 프로토콜은 조합마다 주파수가 바뀌는데 + // 정렬·수동 확인은 2.3MHz c3 고정이라, 조건을 안 적으면 세 숫자를 + // 같은 것으로 놓고 비교한다. + val shown = thickness.combos.getOrElse(comboIdx) { thickness.combos.first() } + val cond = "${shown.first.label}MHz c${shown.second.cycles}" + Collapsible( + title = "회차 상세 — 방광 용적 · 채널 파형", + summary = (runBv?.volumeMl?.let { "%.0f mL · ".format(it) } ?: "") + + (if (running) "직전 회차" else "마지막 회차") + " · $cond", + ) { + com.medithings.vesiscan.ui.components.BvPanel( + runBv, + caption = (if (running) "직전 회차" else "마지막 회차") + " · $cond · 1 cycle", + compact = true, + ) + if (running) { + Spacer(Modifier.height(12.dp)) + Label("채널 파형") + Spacer(Modifier.height(6.dp)) + SixChannelWaveformGrid( + channels = liveChannels, + walls = runBv?.markers ?: emptyList(), + modifier = Modifier.fillMaxWidth(), + cellHeight = 140.dp, + compact = true, + ) + if (liveChannels.isEmpty()) { + Hint("첫 회차를 받으면 파형이 나타납니다.") } } } + Spacer(Modifier.height(10.dp)) + } - if (okCount > 0 || failCount > 0) { - Spacer(Modifier.height(12.dp)) - Text("직전 실행 — 저장 $okCount · 실패 $failCount", - fontSize = 13.sp, color = MlSecondaryText) + // C. labdb 업로드 · 기록 — 쉬는 동안만. 조합이 끝날 때마다 자동으로 올라간다. + // 인터넷이 끊겨 있었으면 여기 남아 있다가 버튼으로 한꺼번에 올린다 — 병원 + // 무선망이 불안정한 곳이 많아 "나중에 올리기"가 예외가 아니라 정상 경로다. + // **lastRunDir 에 의존하지 않는다.** 그건 이번 실행의 폴더이고 `remember` 라서 + // 앱을 다시 켜면 null 이 된다 — 그러면 지난 측정을 올릴 길이 없어진다 + // (2026-09-10 병원 39건). isRegistered 로도 감싸지 않는다 — 미등록일 때도 몇 건이 + // 못 올라갔는지는 보여야 한다. 미업로드가 있으면 머리줄이 주황이다. + if (!running && (pendingUploads > 0 || lastRunDir != null || okCount > 0 || failCount > 0)) { + Collapsible( + title = "labdb 업로드 · 기록", + summary = (if (pendingUploads > 0) "미업로드 ${pendingUploads}건 (지난 측정 포함)" + else "업로드 완료") + + (if (okCount > 0 || failCount > 0) " · 직전 실행 저장 $okCount · 실패 $failCount" else ""), + warn = pendingUploads > 0 || failCount > 0 || configFailed != null, + ) { + (HospitalLabdbUploader.uploadingName.value ?: uploadMsg)?.let { + Text(it, fontSize = 12.sp, color = MlSecondaryText) + Spacer(Modifier.height(8.dp)) + } + if (pendingUploads > 0) { + Button( + onClick = { + uploadBusy = true + uploadScope.launch { + // 전 폴더를 올린다 — 지난 측정까지. + uploadMsg = HospitalLabdbUploader.uploadAllPending() + pendingUploads = + HospitalLabdbUploader.pendingAll().size + uploadBusy = false + } + }, + // 미등록 상태로 누르면 전부 실패 마커가 쌓이고, 그 + // 마커 때문에 나중에 성공 여부를 헷갈리게 된다. + enabled = !uploadBusy && LabdbCredentials.isRegistered, + modifier = Modifier.fillMaxWidth().height(46.dp), + shape = RoundedCornerShape(12.dp), + ) { + Text( + if (uploadBusy) "업로드 중…" else "미업로드 전부 올리기", + fontWeight = FontWeight.Bold, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + "요청 사이를 띄워 보냅니다 — ${pendingUploads}건이면 약 " + + "${(pendingUploads - 1) * 8 / 60 + 1}분 걸립니다.", + fontSize = 11.sp, color = MlSecondaryText, + ) + } // 설정이 거부된 조합이 있었으면 반드시 눈에 띄어야 한다. 그 조합은 // 데이터가 통째로 비어 있고, 방광을 다시 채우지 않으면 못 메운다. configFailed?.let { @@ -755,53 +777,49 @@ fun HospitalModeView(appState: AppState) { modifier = Modifier.padding(top = 6.dp)) } } + Spacer(Modifier.height(10.dp)) } - // ── 여기부터는 프로토콜과 **별개인 수동 도구** ────────────────── - // 위쪽은 "정해진 순서대로 받는" 일이고, 아래쪽은 "지금 궁금한 것을 직접 재 - // 보는" 일이다. 둘이 한 흐름으로 붙어 있으면 조작자가 수동 측정을 프로토콜의 - // 한 단계로 착각한다 — 그 값은 저장도 업로드도 되지 않는다. + // D. 프로토콜과 **별개인 수동 도구**. 위쪽은 "정해진 순서대로 받는" 일이고, + // 여기는 "지금 궁금한 것을 직접 재 보는" 일이다. 한 흐름으로 붙어 있으면 + // 조작자가 수동 측정을 프로토콜의 한 단계로 착각한다 — 그 값은 저장도 업로드도 + // 되지 않는다. 그래서 접어 두고 제목에 못 박는다. + Collapsible(title = "수동 확인 · 프로브 설정 — 저장되지 않습니다") { + ProbeParamCard(bleManager = bleManager, enabled = !running) + + // BV 측정 (1회 / 연속) — 프로토콜을 돌리기 전에 값이 말이 되는지 확인하는 + // 자리다. 정렬이 고른 위치가 실제로 최적인지는 그 위치의 BV 로만 판단할 수 있다. + Spacer(Modifier.height(16.dp)) + BvMeasureSection( + bleManager = bleManager, + isConnected = isConnected && !running, + patient = patient, + posture = posture, + fill = fill, + anchorCm = appState.anchorCm, + anchorBasis = appState.anchorBasis, + ) + + Spacer(Modifier.height(16.dp)) + Text( + "저장 위치: Downloads/VesiScan_Hospital/", + fontSize = 11.sp, color = MlSecondaryText, + ) + // 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의 + // 한계를 알고 있어야 한다. 남는 값은 **이번에 돈 것 중 마지막** 조합이다. + // 두께에 따라 달라지므로 박아 두지 않고 실제 값을 보여준다. + val lastCombo = thickness.combos.lastOrNull() + Text( + "⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " + + "avg ${HospitalFixedParams.AVG} · delay ${HospitalFixedParams.DELAY_US}µs · " + + "samples ${HospitalFixedParams.SAMPLES}. 측정이 끝나면 마지막으로 돈 조합" + + (lastCombo?.let { "(${it.first.label}MHz · cycle ${it.second.cycles})" } ?: "") + + " 설정이 프로브에 남아 일반 측정에도 적용됩니다.", + fontSize = 11.sp, color = Color(0xFFB26A00), + modifier = Modifier.padding(top = 6.dp), + ) + } Spacer(Modifier.height(24.dp)) - HorizontalDivider(thickness = 1.dp, color = MlSecondaryText.copy(alpha = 0.35f)) - Spacer(Modifier.height(6.dp)) - Text("수동 확인 — 저장되지 않습니다", fontSize = 11.sp, color = MlSecondaryText) - Spacer(Modifier.height(14.dp)) - - ProbeParamCard(bleManager = bleManager, enabled = !running) - - // ── BV 측정 (1회 / 연속) ─────────────────────────────────────── - // 프로토콜을 돌리기 전에 값이 말이 되는지 확인하는 자리다. 정렬이 고른 - // 위치가 실제로 최적인지는 그 위치의 BV 로만 판단할 수 있다. - Spacer(Modifier.height(16.dp)) - BvMeasureSection( - bleManager = bleManager, - isConnected = isConnected && !running, - patient = patient, - posture = posture, - fill = fill, - anchorCm = appState.anchorCm, - anchorBasis = appState.anchorBasis, - ) - - Spacer(Modifier.height(16.dp)) - Text( - "저장 위치: Downloads/VesiScan_Hospital/", - fontSize = 11.sp, color = MlSecondaryText, - ) - // 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의 - // 한계를 알고 있어야 한다. - // 남는 값은 **이번에 돈 것 중 마지막** 조합이다. 두께에 따라 달라지므로 - // 박아 두지 않고 실제 값을 보여준다. - val lastCombo = thickness.combos.lastOrNull() - Text( - "⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " + - "avg ${HospitalFixedParams.AVG} · delay ${HospitalFixedParams.DELAY_US}µs · " + - "samples ${HospitalFixedParams.SAMPLES}. 측정이 끝나면 마지막으로 돈 조합" + - (lastCombo?.let { "(${it.first.label}MHz · cycle ${it.second.cycles})" } ?: "") + - " 설정이 프로브에 남아 일반 측정에도 적용됩니다.", - fontSize = 11.sp, color = Color(0xFFB26A00), - modifier = Modifier.padding(top = 6.dp, bottom = 24.dp), - ) } } } @@ -816,6 +834,49 @@ private fun guardRestoreFailed(note: String): Boolean = @Composable private fun Hint(text: String) = Text(text, fontSize = 12.sp, color = MlSecondaryText, modifier = Modifier.padding(top = 6.dp)) +/** + * 접는 구간(2026-09-29). 머리줄은 늘 보이고 [summary] 를 적는다 — 펴 보지 않아도 + * 상태(미정렬·미업로드)가 읽혀야 한다. [warn] 이면 머리줄이 주황이다. 처음엔 접혀 있다. + */ +@Composable +private fun Collapsible( + title: String, + summary: String? = null, + warn: Boolean = false, + content: @Composable ColumnScope.() -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + val tone = if (warn) MlWarning else MlSecondaryText + Column( + Modifier.fillMaxWidth() + .background(Color.White, RoundedCornerShape(12.dp)) + .border(1.dp, tone.copy(alpha = if (warn) 0.6f else 0.3f), RoundedCornerShape(12.dp)), + ) { + Row( + Modifier.fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text(title, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + color = if (warn) MlWarning else MlNeutral) + summary?.let { + Text(it, fontSize = 12.sp, color = tone, modifier = Modifier.padding(top = 2.dp)) + } + } + Icon( + if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = if (expanded) "접기" else "펴기", + tint = MlSecondaryText, + ) + } + if (expanded) { + Column(Modifier.padding(start = 12.dp, end = 12.dp, bottom = 12.dp), content = content) + } + } +} + @Composable private fun StatusRow(connected: Boolean, deviceName: String?) { val color = if (connected) Color(0xFF4CAF50) else Color(0xFFD32F2F)