ui(hospital): 측정 중 보는 넷(기울기·채움·접촉·시작/중지)을 한 화면에 — 나머지는 접는 구간으로

2026-09-29 임상 참관: 측정하는 동안 실제로 보는 것은 IMU 기울기 · 방광 채움 정도 ·
채널 접촉 · 측정 시작/중지 넷인데, 그 사이에 정렬 카드·반복 횟수·복부 두께가 끼고
아래로는 용적·파형·업로드·수동 도구가 이어져 스크롤 없이는 넷을 같이 볼 수 없었다.

늘 보이는 것(위에서 아래로):
  연결 → labdb → 환자명 → 자세 → IMU 기울기 → 채움 정도 → 측정 시작/중지 → 채널 접촉(측정 중)
  · 측정 중에는 labdb·환자명·자세가 어차피 잠기므로 한 줄 요약으로 접는다.
  · 채널 접촉은 판정이 떴을 때만이 아니라 측정 중 늘 띄운다 — 안 보이면 "정상"과
    "아직 판정 없음"을 가를 수 없다. 색이 말한다.
  · 프로브 설정 복원 안내는 버튼 아래로 — 위에 두면 실행마다 버튼 자리가 밀린다.
  · Sitting 각도 안내 한 줄은 뺐다 — 바로 아래 카드가 같은 말을 한다.

접는 구간(Collapsible, 처음엔 접힘, 머리줄에 요약):
  A. 측정 조건 · 부착 위치 — 정렬 카드 · 반복 횟수 · 복부 두께. 미정렬이면 주황.
  B. 회차 상세 — 방광 용적 · 채널 파형 (측정 중·직후만). 머리줄에 mL.
  C. labdb 업로드 · 기록 — 미업로드 n건 · 직전 실행 저장/실패. 미업로드면 주황.
  D. 수동 확인 · 프로브 설정 — 저장되지 않습니다.

A34 실기: 쉬는 화면은 측정 시작까지 스크롤 없이 들어온다. 접는 구간 셋 펴고 접힘 확인.
측정 중 화면은 프로브 없이 확인하지 못했다 — 다음 임상에서 볼 것.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-29 14:10:35 +09:00
parent fef3eb28dd
commit de4a3fbb70
@@ -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)