feat(clinical): 수동 확인을 폰에 기록 — CSV(분석) + JSON(보관)
구분선 아래 1회/연속 측정은 화면에만 떴다가 사라졌다. 검증 항목 ③(위치 민감도 —
부착 위치에서 상·하·좌·우 1~2cm 옮겨 비교)이 바로 그 측정으로 하는 일인데, 남는 게
없으면 간호사가 종이에 받아 적어야 한다.
labdb 에는 올리지 않는다(사용자 결정). 저장 위치:
Downloads/VesiScan_Hospital/<날짜_환자>/manual/
manual_2026-09-09.csv 하루치 이어 붙임 — 행 하나 = 한 번의 확인
manual_2026-09-09_143052.json 누를 때마다 하나 — 6채널 원신호 + 채널별 벽
**하위 폴더인 것이 중요하다.** HospitalLabdbUploader.pending 과
HospitalRunStore.scanProgress 는 둘 다 폴더 최상위만 훑으므로, 여기 둔 CSV 가 600 으로
올라가지도 않고 진행 판정에 끼지도 않는다. 600 은 정확도 검증의 모집단이라, 조작자가
아무 자리에서 아무 때나 누른 값이 섞이면 "정해진 조건에서 받은 데이터"라는 전제가 깨진다.
## 자동 저장하지 않는다
연속 모드는 초당 두 번 값을 낸다. 자동으로 남기면 의미 없는 행이 수백 개 쌓여 정작
비교하려는 위치별 값이 묻힌다. 조작자가 "이 자리 값은 남길 것"이라고 판단한 순간만
[이 확인 기록] 으로 남긴다.
## 위치를 자유 문구로 받지 않는다
ManualCheckStore.Offset 9개(기준 · 위1·2 · 아래1·2 · 좌1·2 · 우1·2)로 고정하고, 메모는
그 옆에 따로 받는다. 자유 입력이면 같은 자리가 "좌1"·"왼쪽 1cm"·"L1" 로 적혀 나중에
묶을 수 없다 — 항목 ③ 은 위치끼리 값을 비교하는 일이라 표기가 흔들리면 데이터가 아니라
메모가 된다. 3열 격자로 두어 방향이 눈에 보이게 했다(드롭다운은 잘못 고른 것도 못 본다).
## 대표값은 화면과 같은 식이어야 한다
파일에 산술평균이 들어가면 보고서를 쓰는 사람이 화면을 믿어야 할지 파일을 믿어야 할지
알 수 없다. 두 trimmedMean 을 테스트에서 직접 맞댔다.
## 저장 실패를 조용히 넘기지 않는다
save 가 null 이면 빨간 글씨로 띄운다. 조용히 넘기면 조작자는 기록됐다고 믿고 다음
위치로 옮겨, 그 자리를 다시 잴 기회를 잃는다.
BvMeasureSection 이 supine:Boolean 대신 posture·fill·patient·anchorCm·anchorBasis 를
받는다. CSV 한 행만 보고 "어떤 상태에서 잰 값인가"에 답할 수 있어야 한다.
테스트 121개 통과(신규 7). 메모의 쉼표·따옴표·줄바꿈이 열을 밀지 않는지, 오프셋 기록
문자열이 고정인지를 잡았다 — 쉼표 하나가 그 행의 뒷열을 전부 한 칸씩 밀어 CSV 가
조용히 어긋나는 것이 제일 찾기 어렵다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,246 @@
|
||||
/*
|
||||
* ManualCheckStore — 수동 BV 확인 기록. 폰에만 남긴다(labdb 업로드 없음).
|
||||
*/
|
||||
package com.medithings.vesiscan.services
|
||||
|
||||
import com.medithings.vesiscan.ble.PiezoChannelData
|
||||
import com.medithings.vesiscan.managers.AnchorBasis
|
||||
import com.medithings.vesiscan.managers.ClinicalBv
|
||||
import com.medithings.vesiscan.models.BladderFill
|
||||
import com.medithings.vesiscan.models.ClinicalPosture
|
||||
import com.medithings.vesiscan.models.HospitalFixedParams
|
||||
import com.medithings.vesiscan.models.ProbeCycle
|
||||
import com.medithings.vesiscan.models.ProbeFrequency
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import kotlin.math.sqrt
|
||||
|
||||
/**
|
||||
* 병원 임상 화면 아래쪽 **수동 확인**(1회/연속 측정)의 기록.
|
||||
*
|
||||
* ## 왜 프로토콜 데이터와 섞지 않는가
|
||||
* 600(프로토콜)은 정확도 검증의 **모집단**이다. 수동 확인은 조작자가 궁금해서 아무
|
||||
* 자리에서 아무 때나 누르는 것이라, 같은 곳에 쌓이면 "정해진 조건에서 받은 데이터"라는
|
||||
* 전제가 깨진다. 그래서 같은 환자·날짜 폴더 **밑의 `manual/`** 에 따로 둔다.
|
||||
*
|
||||
* 하위 폴더인 것이 중요하다. [labdb.HospitalLabdbUploader.pending] 과
|
||||
* [HospitalRunStore.scanProgress] 는 둘 다 폴더 **최상위만** 훑으므로, 여기 둔 CSV 는
|
||||
* 600 으로 올라가지도 않고 진행 판정에 끼지도 않는다.
|
||||
*
|
||||
* ## 파일이 둘인 이유
|
||||
* **CSV 는 분석용**이다 — 하루치를 한 파일에 이어 붙여, USB 로 빼서 엑셀로 열면 행끼리
|
||||
* 바로 비교된다. 검증 항목 ③(위치 민감도)이 "부착 위치에서 상·하·좌·우 1~2cm 옮겨
|
||||
* 비교"라 위치 = 행이어야 한다.
|
||||
*
|
||||
* **JSON 은 보관용**이다 — 누를 때마다 하나씩, 6채널 원신호와 채널별 벽 위치까지.
|
||||
* 숫자가 이상할 때 "그때 파형이 어땠나"로 돌아갈 수 있어야 한다. CSV 에 원신호까지
|
||||
* 넣으면 한 행이 600열을 넘어 사람이 못 읽는다.
|
||||
*
|
||||
* ## 위치를 자유 문구로 받지 않는다
|
||||
* [Offset] 으로 고정한다. 자유 입력이면 같은 자리가 "좌1"·"왼쪽 1cm"·"L1" 로 적혀
|
||||
* 나중에 묶을 수 없다 — 항목 ③ 은 위치끼리 값을 비교하는 일이라 표기가 흔들리면
|
||||
* 데이터가 아니라 메모가 된다. 메모는 그 옆에 따로 받는다.
|
||||
*/
|
||||
object ManualCheckStore {
|
||||
|
||||
/** 부착 위치를 기준으로 한 오프셋. 항목 ③(위치 민감도)이 이 축으로 비교한다. */
|
||||
enum class Offset(val label: String, val wire: String) {
|
||||
BASE("기준", "base"),
|
||||
UP_1("위 1cm", "up_1"),
|
||||
UP_2("위 2cm", "up_2"),
|
||||
DOWN_1("아래 1cm", "down_1"),
|
||||
DOWN_2("아래 2cm", "down_2"),
|
||||
LEFT_1("좌 1cm", "left_1"),
|
||||
LEFT_2("좌 2cm", "left_2"),
|
||||
RIGHT_1("우 1cm", "right_1"),
|
||||
RIGHT_2("우 2cm", "right_2"),
|
||||
}
|
||||
|
||||
data class Saved(val csv: File, val json: File)
|
||||
|
||||
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
private val stampFmt = SimpleDateFormat("HHmmss", Locale.US)
|
||||
private val isoFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.US)
|
||||
|
||||
/** 같은 환자·날짜 폴더 밑 `manual/`. */
|
||||
fun dir(patient: String, at: Date): File =
|
||||
File(HospitalRunStore.runDir(patient, at), "manual").apply { if (!exists()) mkdirs() }
|
||||
|
||||
private val COLUMNS = listOf(
|
||||
"timestamp", "patient", "posture", "fill_pct",
|
||||
"anchor_cm", "anchor_basis", "offset", "memo",
|
||||
"mode", "freq_mhz", "freq_option", "cycles",
|
||||
"avg", "delay_us", "samples", "hw_preset", "preset_confident", "algo",
|
||||
"n", "trimmed_ml", "mean_ml", "sd_ml", "cv_pct", "min_ml", "max_ml",
|
||||
"detected_center", "failure", "snapshot_json",
|
||||
)
|
||||
|
||||
/**
|
||||
* 한 번의 확인을 남긴다. CSV 한 행 + JSON 한 개.
|
||||
*
|
||||
* @param volumes 이 확인의 BV 들. 연속이면 표시 창 그대로, 1회면 값 하나.
|
||||
* @return 실패하면 null — 호출부가 화면에 알려야 한다. 조용히 넘기면 조작자는
|
||||
* 기록됐다고 믿고 다음 위치로 옮겨, 그 자리를 다시 잴 기회를 잃는다.
|
||||
*/
|
||||
fun save(
|
||||
patient: String,
|
||||
at: Date,
|
||||
posture: ClinicalPosture,
|
||||
fill: BladderFill,
|
||||
anchorCm: Int?,
|
||||
anchorBasis: AnchorBasis?,
|
||||
offset: Offset,
|
||||
memo: String,
|
||||
/** `"spot"` 또는 `"continuous"`. */
|
||||
mode: String,
|
||||
freq: ProbeFrequency,
|
||||
cycle: ProbeCycle,
|
||||
volumes: List<Double>,
|
||||
outcome: ClinicalBv.Outcome?,
|
||||
raw: List<PiezoChannelData>,
|
||||
): Saved? = runCatching {
|
||||
val d = dir(patient, at)
|
||||
val stamp = stampFmt.format(at)
|
||||
val jsonName = "manual_${dayFmt.format(at)}_$stamp.json"
|
||||
|
||||
val n = volumes.size
|
||||
val mean = if (n > 0) volumes.average() else Double.NaN
|
||||
val sd = if (n > 1) sqrt(volumes.sumOf { (it - mean) * (it - mean) } / n) else 0.0
|
||||
// 대표값은 화면과 **같은 식**이어야 한다. 화면은 절사평균을 보여주는데 파일에
|
||||
// 산술평균이 들어가면 둘이 다른 수가 되어, 보고서와 화면을 나란히 둘 수 없다.
|
||||
val trimmed = if (n > 0) trimmedMean(volumes) else Double.NaN
|
||||
|
||||
val row = listOf(
|
||||
isoFmt.format(at), patient, posture.label, fill.percent.toString(),
|
||||
anchorCm?.toString() ?: "", anchorBasis?.wire ?: "", offset.wire, memo,
|
||||
mode, freq.label, freq.freqOption.toString(), cycle.cycles.toString(),
|
||||
HospitalFixedParams.AVG.toString(), HospitalFixedParams.DELAY_US.toString(),
|
||||
HospitalFixedParams.SAMPLES.toString(),
|
||||
outcome?.preset ?: "", (outcome?.presetConfident ?: false).toString(),
|
||||
outcome?.algoLabel ?: "",
|
||||
n.toString(), fmt(trimmed), fmt(mean), fmt(sd),
|
||||
fmt(if (mean > 0) sd / mean * 100 else 0.0),
|
||||
fmt(volumes.minOrNull()), fmt(volumes.maxOrNull()),
|
||||
outcome?.detectedCenter?.toString() ?: "", outcome?.failure ?: "",
|
||||
jsonName,
|
||||
).map(::csvField)
|
||||
|
||||
// 하루치를 한 파일에 이어 붙인다. 헤더는 처음 만들 때만.
|
||||
val csv = File(d, "manual_${dayFmt.format(at)}.csv")
|
||||
val fresh = !csv.exists() || csv.length() == 0L
|
||||
csv.appendText(
|
||||
(if (fresh) COLUMNS.joinToString(",") + "\n" else "") + row.joinToString(",") + "\n",
|
||||
Charsets.UTF_8,
|
||||
)
|
||||
|
||||
val json = File(d, jsonName)
|
||||
json.writeText(
|
||||
snapshot(
|
||||
at, patient, posture, fill, anchorCm, anchorBasis, offset, memo,
|
||||
mode, freq, cycle, volumes, trimmed, mean, sd, outcome, raw,
|
||||
).toString(2),
|
||||
Charsets.UTF_8,
|
||||
)
|
||||
Saved(csv, json)
|
||||
}.getOrElse {
|
||||
android.util.Log.w("ManualCheckStore", "수동 확인 저장 실패: ${it.message}")
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* 최대·최소 하나씩 버린 평균. 화면(`BvMeasureSection.trimmedMean`)과 **같은 식**이어야
|
||||
* 한다 — 화면은 절사평균을 보여주는데 파일에 다른 수가 들어가면 보고서와 화면을
|
||||
* 나란히 둘 수 없다. `ManualCheckStoreTest` 가 두 함수를 직접 맞대어 고정한다.
|
||||
*/
|
||||
internal fun trimmedMean(values: List<Double>): Double {
|
||||
if (values.size <= 2) return values.average()
|
||||
val sorted = values.sorted()
|
||||
return sorted.subList(1, sorted.size - 1).average()
|
||||
}
|
||||
|
||||
private fun snapshot(
|
||||
at: Date,
|
||||
patient: String,
|
||||
posture: ClinicalPosture,
|
||||
fill: BladderFill,
|
||||
anchorCm: Int?,
|
||||
anchorBasis: AnchorBasis?,
|
||||
offset: Offset,
|
||||
memo: String,
|
||||
mode: String,
|
||||
freq: ProbeFrequency,
|
||||
cycle: ProbeCycle,
|
||||
volumes: List<Double>,
|
||||
trimmed: Double,
|
||||
mean: Double,
|
||||
sd: Double,
|
||||
outcome: ClinicalBv.Outcome?,
|
||||
raw: List<PiezoChannelData>,
|
||||
): JSONObject = JSONObject().apply {
|
||||
put("kind", "manual_bv_check")
|
||||
put("timestamp", isoFmt.format(at))
|
||||
put("patient", patient)
|
||||
put("patient_unnamed", patient.isBlank())
|
||||
put("posture", posture.label)
|
||||
put("fill_pct", fill.percent)
|
||||
put("anchor_cm", anchorCm ?: JSONObject.NULL)
|
||||
put("anchor_basis", anchorBasis?.wire ?: JSONObject.NULL)
|
||||
put("offset", offset.wire)
|
||||
put("offset_label", offset.label)
|
||||
put("memo", memo)
|
||||
put("mode", mode)
|
||||
put("freq_mhz", freq.label)
|
||||
put("freq_option", freq.freqOption)
|
||||
put("cycles", cycle.cycles)
|
||||
put("avg", HospitalFixedParams.AVG)
|
||||
put("delay_us", HospitalFixedParams.DELAY_US)
|
||||
put("samples", HospitalFixedParams.SAMPLES)
|
||||
put("hw_preset", outcome?.preset ?: JSONObject.NULL)
|
||||
put("preset_confident", outcome?.presetConfident ?: false)
|
||||
put("algo", outcome?.algoLabel ?: JSONObject.NULL)
|
||||
put("volumes_ml", JSONArray().apply { volumes.forEach { put(it) } })
|
||||
put("trimmed_ml", if (trimmed.isFinite()) trimmed else JSONObject.NULL)
|
||||
put("mean_ml", if (mean.isFinite()) mean else JSONObject.NULL)
|
||||
put("sd_ml", sd)
|
||||
put("detected_center", outcome?.detectedCenter ?: JSONObject.NULL)
|
||||
put("failure", outcome?.failure ?: JSONObject.NULL)
|
||||
// 채널별 벽 — 숫자가 이상할 때 제일 먼저 보는 것이다.
|
||||
put("walls", JSONArray().apply {
|
||||
outcome?.walls?.forEach { w ->
|
||||
put(if (w == null) JSONObject.NULL else JSONObject().apply {
|
||||
put("ch", w.channel)
|
||||
put("ant", w.ant); put("post", w.post)
|
||||
put("ant_mm", w.antMm); put("post_mm", w.postMm)
|
||||
put("diameter_mm", w.diameterMm ?: JSONObject.NULL)
|
||||
put("used_in_bv", w.usedInBv)
|
||||
})
|
||||
}
|
||||
})
|
||||
put("channels", JSONArray().apply {
|
||||
raw.sortedBy { it.channel }.forEach { c ->
|
||||
put(JSONObject().apply {
|
||||
put("ch", c.channel)
|
||||
val buf = c.buffer
|
||||
val peak = buf.maxOrNull() ?: 0
|
||||
put("peak", peak)
|
||||
put("peakIdx", buf.indexOfFirst { it == peak })
|
||||
put("data", JSONArray().apply { buf.forEach { put(it) } })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun fmt(v: Double?): String =
|
||||
if (v == null || !v.isFinite()) "" else "%.2f".format(v)
|
||||
|
||||
/** 쉼표·따옴표·줄바꿈이 섞인 메모가 열을 밀지 않게 한다. */
|
||||
internal fun csvField(s: String): String =
|
||||
if (s.any { it == ',' || it == '"' || it == '\n' || it == '\r' })
|
||||
"\"" + s.replace("\"", "\"\"").replace("\r\n", " ").replace('\n', ' ')
|
||||
.replace('\r', ' ') + "\""
|
||||
else s
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.*
|
||||
@@ -16,10 +18,14 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.medithings.vesiscan.ble.BleManager
|
||||
import com.medithings.vesiscan.ble.PiezoChannelData
|
||||
import com.medithings.vesiscan.managers.AnchorBasis
|
||||
import com.medithings.vesiscan.managers.ClinicalBv
|
||||
import com.medithings.vesiscan.models.BladderFill
|
||||
import com.medithings.vesiscan.models.ClinicalPosture
|
||||
import com.medithings.vesiscan.models.HospitalFixedParams
|
||||
import com.medithings.vesiscan.models.ProbeCycle
|
||||
import com.medithings.vesiscan.models.ProbeFrequency
|
||||
import com.medithings.vesiscan.services.ManualCheckStore
|
||||
import com.medithings.vesiscan.ui.components.BvPanel
|
||||
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
|
||||
import com.medithings.vesiscan.ui.theme.*
|
||||
@@ -88,9 +94,19 @@ fun BvMeasureSection(
|
||||
bleManager: BleManager,
|
||||
isConnected: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
/** 자세가 누움이면 BV 파이프라인이 nch=2 clamp 와 F83 에서 그것을 본다. */
|
||||
supine: Boolean = false,
|
||||
/**
|
||||
* 기록에 남길 맥락. 화면에 쓰지 않는 값도 받는 이유는, 나중에 CSV 한 행만 보고
|
||||
* "어떤 상태에서 잰 값인가"에 답할 수 있어야 하기 때문이다 — 자세·충만도가 빠지면
|
||||
* 위치끼리 비교(검증 항목 ③)가 성립하지 않는다.
|
||||
*/
|
||||
patient: String = "",
|
||||
posture: ClinicalPosture = ClinicalPosture.SUPINE,
|
||||
fill: BladderFill = BladderFill.P0,
|
||||
anchorCm: Int? = null,
|
||||
anchorBasis: AnchorBasis? = null,
|
||||
) {
|
||||
// 자세가 누움이면 BV 파이프라인이 nch=2 clamp 와 F83 에서 그것을 본다.
|
||||
val supine = posture == ClinicalPosture.SUPINE
|
||||
var mode by remember { mutableStateOf<Mode?>(null) } // null = 정지
|
||||
var outcome by remember { mutableStateOf<ClinicalBv.Outcome?>(null) }
|
||||
var live by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
|
||||
@@ -99,9 +115,19 @@ fun BvMeasureSection(
|
||||
var window by remember { mutableStateOf<List<Double>>(emptyList()) }
|
||||
var tick by remember { mutableIntStateOf(0) }
|
||||
|
||||
// ── 기록 ────────────────────────────────────────────────────────────────
|
||||
// 마지막으로 **시작한** 모드. mode 는 1회 측정이 끝나면 null 로 돌아가므로, 끝난 뒤
|
||||
// 기록할 때 "무엇으로 쟀나"를 답할 수 없다.
|
||||
var lastMode by remember { mutableStateOf<Mode?>(null) }
|
||||
var offset by remember { mutableStateOf(ManualCheckStore.Offset.BASE) }
|
||||
var memo by remember { mutableStateOf("") }
|
||||
var saveMsg by remember { mutableStateOf<String?>(null) }
|
||||
var saveFailed by remember { mutableStateOf(false) }
|
||||
|
||||
|
||||
LaunchedEffect(mode) {
|
||||
val m = mode ?: return@LaunchedEffect
|
||||
lastMode = m
|
||||
error = null
|
||||
if (m == Mode.CONTINUOUS) { window = emptyList(); tick = 0 }
|
||||
val inbox = kotlinx.coroutines.channels.Channel<List<PiezoChannelData>>(
|
||||
@@ -191,14 +217,17 @@ fun BvMeasureSection(
|
||||
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
Button(
|
||||
onClick = { mode = if (mode == Mode.SPOT) null else Mode.SPOT },
|
||||
onClick = { saveMsg = null; mode = if (mode == Mode.SPOT) null else Mode.SPOT },
|
||||
enabled = isConnected && mode != Mode.CONTINUOUS,
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) { Text(if (mode == Mode.SPOT) "측정 중…" else "1회 측정", fontWeight = FontWeight.Bold) }
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Button(
|
||||
onClick = { mode = if (mode == Mode.CONTINUOUS) null else Mode.CONTINUOUS },
|
||||
onClick = {
|
||||
saveMsg = null
|
||||
mode = if (mode == Mode.CONTINUOUS) null else Mode.CONTINUOUS
|
||||
},
|
||||
enabled = isConnected && mode != Mode.SPOT,
|
||||
modifier = Modifier.weight(1f).height(46.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
@@ -268,6 +297,81 @@ fun BvMeasureSection(
|
||||
},
|
||||
)
|
||||
|
||||
// ── 이 확인을 남기기 ──
|
||||
// 기본은 남기지 않는다. 연속 모드는 초당 두 번 값을 내므로 자동 저장하면 의미
|
||||
// 없는 행이 수백 개 쌓여, 정작 비교하려는 위치별 값이 묻힌다. 조작자가 "이 자리
|
||||
// 값은 남길 것"이라고 판단한 순간만 기록한다.
|
||||
val saveVolumes = if (window.isNotEmpty()) window else listOfNotNull(outcome?.volumeMl)
|
||||
if (outcome != null) {
|
||||
Spacer(Modifier.height(14.dp))
|
||||
Column(
|
||||
Modifier.fillMaxWidth()
|
||||
.background(MlPrimary.copy(alpha = 0.05f), RoundedCornerShape(12.dp))
|
||||
.padding(12.dp)
|
||||
) {
|
||||
Text("이 확인 기록하기", fontSize = 13.sp, fontWeight = FontWeight.Bold)
|
||||
Text(
|
||||
"폰에만 저장됩니다 — Downloads/VesiScan_Hospital/<날짜_환자>/manual/. " +
|
||||
"프로토콜 데이터(labdb)와 섞이지 않습니다.",
|
||||
fontSize = 11.sp, color = MlSecondaryText,
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
|
||||
// 위치는 자유 입력이 아니다. 같은 자리가 "좌1"·"왼쪽 1cm"·"L1" 로 적히면
|
||||
// 나중에 묶을 수 없어, 위치 민감도 비교가 숫자 모음으로 끝난다.
|
||||
Text("부착 위치 기준", fontSize = 11.sp, color = MlSecondaryText)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
OffsetPicker(offset, enabled = true) { offset = it }
|
||||
|
||||
Spacer(Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = memo,
|
||||
onValueChange = { memo = it.take(200) },
|
||||
label = { Text("메모 (선택)") },
|
||||
placeholder = { Text("예: 젤 다시 바름, 환자 호흡 큼") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Spacer(Modifier.height(8.dp))
|
||||
Button(
|
||||
onClick = {
|
||||
val saved = ManualCheckStore.save(
|
||||
patient = patient, at = java.util.Date(),
|
||||
posture = posture, fill = fill,
|
||||
anchorCm = anchorCm, anchorBasis = anchorBasis,
|
||||
offset = offset, memo = memo,
|
||||
mode = if (lastMode == Mode.CONTINUOUS) "continuous" else "spot",
|
||||
freq = BV_FREQ, cycle = BV_CYCLE,
|
||||
volumes = saveVolumes, outcome = outcome, raw = live,
|
||||
)
|
||||
saveFailed = saved == null
|
||||
// 실패를 조용히 넘기면 조작자는 기록됐다고 믿고 다음 위치로
|
||||
// 옮겨, 그 자리를 다시 잴 기회를 잃는다.
|
||||
saveMsg = if (saved == null) "저장 실패 — 저장 공간과 권한을 확인하세요."
|
||||
else "저장됨 · ${offset.label} · ${saveVolumes.size}개 값 → ${saved.csv.name}"
|
||||
},
|
||||
enabled = saveVolumes.isNotEmpty(),
|
||||
modifier = Modifier.fillMaxWidth().height(44.dp),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
) {
|
||||
Text(
|
||||
if (saveVolumes.size > 1) "이 확인 기록 (${saveVolumes.size}개 값)"
|
||||
else "이 확인 기록",
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
if (saveVolumes.isEmpty()) {
|
||||
Text("BV 가 나온 회차가 없어 남길 것이 없습니다.",
|
||||
fontSize = 11.sp, color = MlSecondaryText)
|
||||
}
|
||||
saveMsg?.let {
|
||||
Spacer(Modifier.height(6.dp))
|
||||
Text(it, fontSize = 11.sp,
|
||||
color = if (saveFailed) MlCritical else MlSuccess)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 파형 + 벽 마커 ──
|
||||
// BV 가 안 나올 때 원인을 여기서 본다. 초록·주황 세로선이 전벽·후벽이다.
|
||||
if (live.isNotEmpty()) {
|
||||
@@ -285,6 +389,53 @@ fun BvMeasureSection(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 부착 위치 기준 오프셋 고르기 — 3열로 방향이 눈에 보이게 둔다.
|
||||
*
|
||||
* 드롭다운으로 두지 않은 이유는 항목 ③ 이 같은 자리를 여러 번 오가며 재는 작업이라,
|
||||
* 누를 때마다 목록을 펼치면 잘못 고른 것도 알아채기 어렵기 때문이다.
|
||||
*/
|
||||
@Composable
|
||||
private fun OffsetPicker(
|
||||
selected: ManualCheckStore.Offset,
|
||||
enabled: Boolean,
|
||||
onSelect: (ManualCheckStore.Offset) -> Unit,
|
||||
) {
|
||||
val rows = listOf(
|
||||
listOf(ManualCheckStore.Offset.UP_2, ManualCheckStore.Offset.UP_1,
|
||||
ManualCheckStore.Offset.BASE),
|
||||
listOf(ManualCheckStore.Offset.DOWN_1, ManualCheckStore.Offset.DOWN_2,
|
||||
ManualCheckStore.Offset.LEFT_1),
|
||||
listOf(ManualCheckStore.Offset.LEFT_2, ManualCheckStore.Offset.RIGHT_1,
|
||||
ManualCheckStore.Offset.RIGHT_2),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
rows.forEach { row ->
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
row.forEach { o ->
|
||||
val on = o == selected
|
||||
Box(
|
||||
Modifier.weight(1f)
|
||||
.background(if (on) MlPrimary else Color.White, RoundedCornerShape(8.dp))
|
||||
.then(
|
||||
if (on) Modifier
|
||||
else Modifier.border(1.dp, MlSecondaryText.copy(alpha = 0.3f),
|
||||
RoundedCornerShape(8.dp))
|
||||
)
|
||||
.clickable(enabled = enabled) { onSelect(o) }
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(o.label, fontSize = 12.sp,
|
||||
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
|
||||
color = if (on) Color.White else MlSecondaryText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 최대·최소 하나씩 버린 평균. 메인 화면 `trimmedMean` 과 같은 식. */
|
||||
internal fun trimmedMean(values: List<Double>): Double {
|
||||
if (values.size <= 2) return values.average()
|
||||
|
||||
@@ -645,7 +645,11 @@ fun HospitalModeView(appState: AppState) {
|
||||
BvMeasureSection(
|
||||
bleManager = bleManager,
|
||||
isConnected = isConnected && !running,
|
||||
supine = posture == ClinicalPosture.SUPINE,
|
||||
patient = patient,
|
||||
posture = posture,
|
||||
fill = fill,
|
||||
anchorCm = appState.anchorCm,
|
||||
anchorBasis = appState.anchorBasis,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Reference in New Issue
Block a user