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:
2026-09-09 15:59:22 +09:00
parent d401740210
commit 7926b61667
4 changed files with 475 additions and 5 deletions
@@ -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))