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:
@@ -13,11 +13,17 @@
|
|||||||
* 조작자가 freq·cycle 조합을 손으로 고르면 반드시 빠뜨리거나 잘못 기록한다 — 눈으로
|
* 조작자가 freq·cycle 조합을 손으로 고르면 반드시 빠뜨리거나 잘못 기록한다 — 눈으로
|
||||||
* 보고 답할 수 있는 것은 복부 두께이고, 조합은 거기서 따라 나온다([AbdomenThickness]).
|
* 보고 답할 수 있는 것은 복부 두께이고, 조합은 거기서 따라 나온다([AbdomenThickness]).
|
||||||
*
|
*
|
||||||
* ## 화면은 두 덩어리다
|
* ## 화면은 "늘 보이는 것"과 "접힌 것"이다 (2026-09-29 임상 참관 뒤)
|
||||||
* 구분선 위는 **프로토콜**(환자명 → 정렬 → 자세 → 충만도 → 반복 → 두께 → 측정 시작),
|
* 측정하는 동안 실제로 보는 것은 넷이다 — **기울기 · 채움 정도 · 채널 접촉 · 시작/중지**.
|
||||||
* 아래는 **수동 확인**(프로브 파라미터 주입 · 1회/연속 BV 측정)이다. 아래쪽 값은
|
* 이 넷이 스크롤 없이 한 화면에 있어야 한다. 그래서 위에는 그 흐름만 둔다:
|
||||||
* 저장도 업로드도 되지 않는다 — 한 흐름으로 붙여 두면 조작자가 수동 측정을 프로토콜의
|
*
|
||||||
* 한 단계로 착각한다.
|
* 연결 → labdb → 환자명 → 자세 → 기울기 → 채움 정도 → 측정 시작/중지 → 채널 접촉(측정 중)
|
||||||
|
*
|
||||||
|
* 세션에 한 번 만지거나 안 만지는 것은 아래 접는 구간([Collapsible])이다 — 측정 조건·
|
||||||
|
* 부착 위치(정렬·반복·두께), 회차 상세(용적·파형), labdb 업로드·기록, 수동 확인(프로브
|
||||||
|
* 파라미터 · 1회/연속 BV). 접힌 머리줄에 요약을 적어 미정렬·미업로드는 펴 보지 않아도
|
||||||
|
* 보인다. 수동 확인 값은 저장도 업로드도 되지 않는다 — 한 흐름으로 붙여 두면 조작자가
|
||||||
|
* 수동 측정을 프로토콜의 한 단계로 착각하므로 제목에 못 박고 접어 둔다.
|
||||||
*/
|
*/
|
||||||
package com.medithings.vesiscan.ui.views.clinical
|
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.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@@ -481,15 +489,35 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
|
|
||||||
// 프로브 기울기 — 상시 (2026-09-28 요청). Sitting 을 고르면 요구 범위가 함께 뜬다.
|
// ── 화면 구성 (2026-09-29 임상 참관 뒤) ─────────────────────────
|
||||||
ImuTiltCard(
|
// 측정하는 동안 실제로 보는 것은 넷이다 — 기울기 · 채움 정도 · 채널 접촉 ·
|
||||||
tiltDeg = liveTilt,
|
// 시작/중지. 이 넷이 스크롤 없이 한 화면에 있어야 한다. 그래서 위에는 그 흐름
|
||||||
posture = posture,
|
// (연결 → labdb → 환자 → 자세 → 기울기 → 채움 → 시작 → 접촉)만 두고, 세션에
|
||||||
connected = isConnected,
|
// 한 번 만지거나 안 만지는 것(정렬·반복·두께, 회차 상세, 업로드, 수동 도구)은
|
||||||
running = running,
|
// 아래 [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))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
} else {
|
||||||
|
LabdbStatusCard(enabled = true)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// 3. 환자명
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = patient,
|
value = patient,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
@@ -510,37 +538,24 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
// labdb 등록 상태. **측정 전에** 보여야 한다 — 미등록이면 업로드 시도조차
|
// 4. 자세 — 기울기 카드·정렬보다 위. 카드의 요구 범위(Sitting 35°~55°)와 정렬의
|
||||||
// 일어나지 않는데(ClinicalSessionStore·HospitalLabdbUploader 가 둘 다
|
// BV supine 분기가 이 값을 본다.
|
||||||
// isRegistered 뒤에 있다), 종전에는 업로드 카드가 isRegistered 로 감싸져
|
|
||||||
// 있어 미등록일 때 화면에 아무것도 없었다. 20 cycle × 조합을 받은 뒤에
|
|
||||||
// 알면 방광을 비운 뒤라 다시 잴 수 없다.
|
|
||||||
LabdbStatusCard(enabled = !running)
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
|
|
||||||
// 자세는 **정렬보다 위**에 둔다(2026-09-28). 정렬은 자세에 매인 일이다 — BV 의
|
|
||||||
// supine 분기와 Sitting 의 35°~55° 요구 범위가 이 값을 본다. 정렬 카드가 위에
|
|
||||||
// 있던 동안은 자세를 안 고른 채(기본 Supine) 정렬에 들어가 띠 없이 붙이고 왔다.
|
|
||||||
Label("자세")
|
Label("자세")
|
||||||
ChipRow(ClinicalPosture.entries, posture, { posture = it }, !running,
|
ChipRow(ClinicalPosture.entries, posture, { posture = it }, true,
|
||||||
progressOf = { postureProgress(it) }) { it.label }
|
progressOf = { postureProgress(it) }) { it.label }
|
||||||
PostureTilt.rangeFor(posture)?.let { r ->
|
|
||||||
Hint("${posture.label} 은 프로브 기울기 ${r.start.toInt()}°~${r.endInclusive.toInt()}° 에서 잰다 — 위 카드로 맞춘다")
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
// 연결 여부로 막지 않는다(2026-09-28). 종전에는 `isConnected` 도 조건이라
|
// 5. 프로브 기울기 — 상시 (2026-09-28 요청). Sitting 이면 카드 안에 요구 범위가 뜬다.
|
||||||
// 프로브가 안 붙은 채 [정렬 시작] 을 누르면 아무 일도 없었다 — 글자색이
|
ImuTiltCard(
|
||||||
// 항상 파랑이라 비활성인 줄도 모른다. 정렬 화면에 [기기 연결] 이 있고 측정
|
tiltDeg = liveTilt,
|
||||||
// 버튼은 거기서 연결을 본다. 들어가서 붙이면 된다.
|
posture = posture,
|
||||||
AnchorCard(
|
connected = isConnected,
|
||||||
anchorCm = appState.anchorCm,
|
running = running,
|
||||||
basis = appState.anchorBasis,
|
|
||||||
enabled = !running,
|
|
||||||
onAlign = { appState.currentScreen = AppScreen.ANCHOR_ALIGN },
|
|
||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// 6. 방광 채움 정도
|
||||||
Label("방광 채움 정도 — 간호사가 채워 둔 값")
|
Label("방광 채움 정도 — 간호사가 채워 둔 값")
|
||||||
// 방광 단계는 **지금 고른 자세 안에서**의 진행이다. 자세를 바꾸면 표시도 바뀐다.
|
// 방광 단계는 **지금 고른 자세 안에서**의 진행이다. 자세를 바꾸면 표시도 바뀐다.
|
||||||
ChipRow(BladderFill.entries, fill, { fill = it }, !running,
|
ChipRow(BladderFill.entries, fill, { fill = it }, !running,
|
||||||
@@ -550,51 +565,9 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
if (patient.isBlank()) "환자명을 넣으면 오늘 이 환자의 진행 상황이 표시된다."
|
if (patient.isBlank()) "환자명을 넣으면 오늘 이 환자의 진행 상황이 표시된다."
|
||||||
else "회색 ✓ 완료 · 주황 · 일부(다시 눌러 마저 측정)"
|
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))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
// 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상
|
// 7. 측정 시작 — 돌 때는 진행 패널 + 중지
|
||||||
// 조건으로 돌게 되므로 눈에 띄어야 한다.
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (running) {
|
if (running) {
|
||||||
val (f, c) = thickness.combos.getOrElse(comboIdx) { thickness.combos.first() }
|
val (f, c) = thickness.combos.getOrElse(comboIdx) { thickness.combos.first() }
|
||||||
ProgressPanel(
|
ProgressPanel(
|
||||||
@@ -629,37 +602,105 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
else if (patient.isBlank()) Hint("환자명을 입력해 주세요.")
|
else if (patient.isBlank()) Hint("환자명을 입력해 주세요.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 프로토콜 실행의 BV ──────────────────────────────────────────
|
// 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상
|
||||||
// 돌 때는 직전 회차, 끝난 뒤에는 마지막 회차. 끝나고도 남겨 둔다 — 방광을
|
// 조건으로 돌게 되므로 눈에 띄어야 한다. 버튼 **아래**에 둔다 — 위에 두면
|
||||||
// 비우기 전에 "방금 받은 값이 말이 되나"를 한 번 더 볼 수 있어야 한다.
|
// 실행이 끝날 때마다 버튼 자리가 밀린다.
|
||||||
if (running || runBv != null) {
|
restoreNote?.let { note ->
|
||||||
Spacer(Modifier.height(12.dp))
|
val failed = guardRestoreFailed(note)
|
||||||
Label("방광 용적")
|
Spacer(Modifier.height(10.dp))
|
||||||
Spacer(Modifier.height(6.dp))
|
Text(
|
||||||
// 캡션에 **조건을 적는다.** 프로토콜은 조합마다 주파수가 바뀌는데
|
note,
|
||||||
// 정렬·수동 확인은 2.3MHz c3 고정이라, 조건을 안 적으면 세 숫자를
|
fontSize = 12.sp,
|
||||||
// 같은 것으로 놓고 비교한다.
|
color = if (failed) MlCritical else MlSecondaryText,
|
||||||
val shown = thickness.combos.getOrElse(comboIdx) { thickness.combos.first() }
|
modifier = Modifier
|
||||||
com.medithings.vesiscan.ui.components.BvPanel(
|
.fillMaxWidth()
|
||||||
runBv,
|
.background(
|
||||||
caption = (if (running) "직전 회차" else "마지막 회차") +
|
(if (failed) MlCritical else MlSuccess).copy(alpha = 0.10f),
|
||||||
" · ${shown.first.label}MHz c${shown.second.cycles} · 1 cycle",
|
RoundedCornerShape(8.dp),
|
||||||
compact = true,
|
)
|
||||||
|
.padding(10.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 접촉·파형은 돌 때만 띄운다. 측정하는 사람이 값이 제대로 잡히는지 **그
|
// 8. 채널 접촉 — 돌 때만. 판정이 떴을 때만 보이게 하면 "정상"과 "아직 판정
|
||||||
// 자리에서** 봐야 한다 — 20회를 다 돌린 뒤 파일을 열어 보면 잘못 붙은 것을
|
// 없음"을 화면에서 가를 수 없다. 늘 띄우고 색으로 말한다(회색 확인 중 · 초록
|
||||||
// 알았을 때 이미 그 단계를 다시 잴 수 없다(방광을 비웠거나 자세가 바뀌었다).
|
// 정상 · 빨강 확인 필요). 잘못 붙은 것은 **그 자리에서** 알아야 한다 — 20회를
|
||||||
//
|
// 다 돌린 뒤에는 방광을 비웠거나 자세가 바뀌어 그 단계를 다시 잴 수 없다.
|
||||||
// 셀을 작게 잡는다(140dp). 이 자리의 주인공은 진행 상황과 중지 버튼이라
|
|
||||||
// 크게 잡으면 중지 버튼이 화면 밖으로 밀린다.
|
|
||||||
if (running) {
|
if (running) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
Label("채널 접촉")
|
Label("채널 접촉")
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
ChannelDetachPanel(detachSnapshot)
|
ChannelDetachPanel(detachSnapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 },
|
||||||
|
)
|
||||||
|
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(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))
|
Spacer(Modifier.height(12.dp))
|
||||||
Label("채널 파형")
|
Label("채널 파형")
|
||||||
Spacer(Modifier.height(6.dp))
|
Spacer(Modifier.height(6.dp))
|
||||||
@@ -673,42 +714,31 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
if (liveChannels.isEmpty()) {
|
if (liveChannels.isEmpty()) {
|
||||||
Hint("첫 회차를 받으면 파형이 나타납니다.")
|
Hint("첫 회차를 받으면 파형이 나타납니다.")
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(16.dp))
|
}
|
||||||
} else {
|
}
|
||||||
// ── labdb 업로드 ────────────────────────────────────────────
|
Spacer(Modifier.height(10.dp))
|
||||||
// 조합이 끝날 때마다 자동으로 올라간다. 인터넷이 끊겨 있었으면 여기
|
}
|
||||||
// 남아 있다가 이 버튼으로 한꺼번에 올린다 — 병원 무선망이 불안정한
|
|
||||||
// 곳이 많아 "나중에 올리기"가 예외가 아니라 정상 경로다.
|
// C. labdb 업로드 · 기록 — 쉬는 동안만. 조합이 끝날 때마다 자동으로 올라간다.
|
||||||
// **lastRunDir 에 의존하지 않는다.** 그건 이번 실행의 폴더이고 `remember`
|
// 인터넷이 끊겨 있었으면 여기 남아 있다가 버튼으로 한꺼번에 올린다 — 병원
|
||||||
// 라서 앱을 다시 켜면 null 이 된다 — 그러면 카드가 사라져 지난 측정을 올릴
|
// 무선망이 불안정한 곳이 많아 "나중에 올리기"가 예외가 아니라 정상 경로다.
|
||||||
// 길이 없어진다(2026-09-10 병원 39건).
|
// **lastRunDir 에 의존하지 않는다.** 그건 이번 실행의 폴더이고 `remember` 라서
|
||||||
//
|
// 앱을 다시 켜면 null 이 된다 — 그러면 지난 측정을 올릴 길이 없어진다
|
||||||
// isRegistered 로도 감싸지 않는다. 미등록일 때도 몇 건이 못 올라갔는지는
|
// (2026-09-10 병원 39건). isRegistered 로도 감싸지 않는다 — 미등록일 때도 몇 건이
|
||||||
// 보여야 한다 — 승인 뒤 한꺼번에 보내야 하므로.
|
// 못 올라갔는지는 보여야 한다. 미업로드가 있으면 머리줄이 주황이다.
|
||||||
if (pendingUploads > 0 || lastRunDir != null) {
|
if (!running && (pendingUploads > 0 || lastRunDir != null || okCount > 0 || failCount > 0)) {
|
||||||
Spacer(Modifier.height(12.dp))
|
Collapsible(
|
||||||
Box(
|
title = "labdb 업로드 · 기록",
|
||||||
Modifier.fillMaxWidth()
|
summary = (if (pendingUploads > 0) "미업로드 ${pendingUploads}건 (지난 측정 포함)"
|
||||||
.background(
|
else "업로드 완료") +
|
||||||
if (pendingUploads > 0) Color(0xFFFFF3E0) else MlPrimary.copy(alpha = 0.07f),
|
(if (okCount > 0 || failCount > 0) " · 직전 실행 저장 $okCount · 실패 $failCount" else ""),
|
||||||
RoundedCornerShape(12.dp),
|
warn = pendingUploads > 0 || failCount > 0 || configFailed != null,
|
||||||
)
|
|
||||||
.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 {
|
(HospitalLabdbUploader.uploadingName.value ?: uploadMsg)?.let {
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
Text(it, fontSize = 12.sp, color = MlSecondaryText)
|
Text(it, fontSize = 12.sp, color = MlSecondaryText)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
if (pendingUploads > 0) {
|
if (pendingUploads > 0) {
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
uploadBusy = true
|
uploadBusy = true
|
||||||
@@ -738,14 +768,6 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
fontSize = 11.sp, color = MlSecondaryText,
|
fontSize = 11.sp, color = MlSecondaryText,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (okCount > 0 || failCount > 0) {
|
|
||||||
Spacer(Modifier.height(12.dp))
|
|
||||||
Text("직전 실행 — 저장 $okCount · 실패 $failCount",
|
|
||||||
fontSize = 13.sp, color = MlSecondaryText)
|
|
||||||
// 설정이 거부된 조합이 있었으면 반드시 눈에 띄어야 한다. 그 조합은
|
// 설정이 거부된 조합이 있었으면 반드시 눈에 띄어야 한다. 그 조합은
|
||||||
// 데이터가 통째로 비어 있고, 방광을 다시 채우지 않으면 못 메운다.
|
// 데이터가 통째로 비어 있고, 방광을 다시 채우지 않으면 못 메운다.
|
||||||
configFailed?.let {
|
configFailed?.let {
|
||||||
@@ -755,23 +777,18 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
modifier = Modifier.padding(top = 6.dp))
|
modifier = Modifier.padding(top = 6.dp))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(10.dp))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 여기부터는 프로토콜과 **별개인 수동 도구** ──────────────────
|
// D. 프로토콜과 **별개인 수동 도구**. 위쪽은 "정해진 순서대로 받는" 일이고,
|
||||||
// 위쪽은 "정해진 순서대로 받는" 일이고, 아래쪽은 "지금 궁금한 것을 직접 재
|
// 여기는 "지금 궁금한 것을 직접 재 보는" 일이다. 한 흐름으로 붙어 있으면
|
||||||
// 보는" 일이다. 둘이 한 흐름으로 붙어 있으면 조작자가 수동 측정을 프로토콜의
|
// 조작자가 수동 측정을 프로토콜의 한 단계로 착각한다 — 그 값은 저장도 업로드도
|
||||||
// 한 단계로 착각한다 — 그 값은 저장도 업로드도 되지 않는다.
|
// 되지 않는다. 그래서 접어 두고 제목에 못 박는다.
|
||||||
Spacer(Modifier.height(24.dp))
|
Collapsible(title = "수동 확인 · 프로브 설정 — 저장되지 않습니다") {
|
||||||
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)
|
ProbeParamCard(bleManager = bleManager, enabled = !running)
|
||||||
|
|
||||||
// ── BV 측정 (1회 / 연속) ───────────────────────────────────────
|
// BV 측정 (1회 / 연속) — 프로토콜을 돌리기 전에 값이 말이 되는지 확인하는
|
||||||
// 프로토콜을 돌리기 전에 값이 말이 되는지 확인하는 자리다. 정렬이 고른
|
// 자리다. 정렬이 고른 위치가 실제로 최적인지는 그 위치의 BV 로만 판단할 수 있다.
|
||||||
// 위치가 실제로 최적인지는 그 위치의 BV 로만 판단할 수 있다.
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
BvMeasureSection(
|
BvMeasureSection(
|
||||||
bleManager = bleManager,
|
bleManager = bleManager,
|
||||||
@@ -789,9 +806,8 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
fontSize = 11.sp, color = MlSecondaryText,
|
fontSize = 11.sp, color = MlSecondaryText,
|
||||||
)
|
)
|
||||||
// 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의
|
// 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의
|
||||||
// 한계를 알고 있어야 한다.
|
// 한계를 알고 있어야 한다. 남는 값은 **이번에 돈 것 중 마지막** 조합이다.
|
||||||
// 남는 값은 **이번에 돈 것 중 마지막** 조합이다. 두께에 따라 달라지므로
|
// 두께에 따라 달라지므로 박아 두지 않고 실제 값을 보여준다.
|
||||||
// 박아 두지 않고 실제 값을 보여준다.
|
|
||||||
val lastCombo = thickness.combos.lastOrNull()
|
val lastCombo = thickness.combos.lastOrNull()
|
||||||
Text(
|
Text(
|
||||||
"⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " +
|
"⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " +
|
||||||
@@ -800,9 +816,11 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
(lastCombo?.let { "(${it.first.label}MHz · cycle ${it.second.cycles})" } ?: "") +
|
(lastCombo?.let { "(${it.first.label}MHz · cycle ${it.second.cycles})" } ?: "") +
|
||||||
" 설정이 프로브에 남아 일반 측정에도 적용됩니다.",
|
" 설정이 프로브에 남아 일반 측정에도 적용됩니다.",
|
||||||
fontSize = 11.sp, color = Color(0xFFB26A00),
|
fontSize = 11.sp, color = Color(0xFFB26A00),
|
||||||
modifier = Modifier.padding(top = 6.dp, bottom = 24.dp),
|
modifier = Modifier.padding(top = 6.dp),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(Modifier.height(24.dp))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,6 +834,49 @@ private fun guardRestoreFailed(note: String): Boolean =
|
|||||||
@Composable private fun Hint(text: String) =
|
@Composable private fun Hint(text: String) =
|
||||||
Text(text, fontSize = 12.sp, color = MlSecondaryText, modifier = Modifier.padding(top = 6.dp))
|
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
|
@Composable
|
||||||
private fun StatusRow(connected: Boolean, deviceName: String?) {
|
private fun StatusRow(connected: Boolean, deviceName: String?) {
|
||||||
val color = if (connected) Color(0xFF4CAF50) else Color(0xFFD32F2F)
|
val color = if (connected) Color(0xFF4CAF50) else Color(0xFFD32F2F)
|
||||||
|
|||||||
Reference in New Issue
Block a user