feat(clinical): 병원 임상 측정 모드 — 주파수×cycle 6조합 자동 순회

## 조작자가 하는 일은 하나다
간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도만** 고르고
[측정 시작]을 누른다. 이후 주파수 2종 × cycle 3종 = 6조합을 앱이 자동으로 순회하며
각 조합마다 n회(기본 20) 측정하고 조합별 파일로 저장한다.

조합을 손으로 바꾸게 두면 반드시 빠뜨리거나 잘못 기록한다. 이미 채워 둔 방광은 다시
만들 수 없으니, 그 자리에서 놓친 조합은 그날 데이터에서 영영 빈다. 사람이 개입하는
지점을 하나로 줄인 것이 이 모드의 핵심이다.

  진입: 홈에서 캐릭터 3연타 → 개발자 모드 → [병원 임상 측정]

## 프로토콜
  자세      Supine / Sitting / Standing (기존 ClinicalPosture 재사용)
  주파수    1.8 · 2.3 MHz          → mpa? 첫 인자
  cycle     3 · 5 · 7              → mpa? 둘째 인자
  방광 채움 0/20/40/60/80/100 %    → 사람이 아는 값 = 정답 라벨
  반복      화면 입력 (기본 20)

## 저장
  Downloads/VesiScan_Hospital/{날짜_환자}/
    2026-09-03_홍길동_Supine_040pct_1.8MHz-fopt1_c3.csv
    run_HHmmss.json   ← 계획 대비 실제 저장 수

파일명에 조건을 전부 적는다. 폴더로만 구분하면 파일 하나를 옮기는 순간 조건을 잃는데,
임상 데이터는 나중에 다른 사람이 모아서 분석한다. CSV 열 구성은 기존 AdcCsvLogger 와
맞춰(scan_id·timestamp·channel·s0~s99) 분석 스크립트를 새로 만들지 않아도 되게 했다.

기존 임상 R&D(`VesiScan_Sessions/`)와 폴더를 나눴다 — 목적도 구조도 달라서 섞이면
나중에 파일을 하나씩 열어 봐야 한다.

## ⚠ 주파수 코드값이 확인 전이다
`mpa?` 첫 인자는 정수인데 그 정수와 MHz 의 대응이 코드에도
docs/BLE_PROTOCOL_REFERENCE.md 에도 없다. 기존 코드는 늘 `freqOption = 2` 만 썼다
(PlacementGuideView · MeasurementService). 그래서 1.8→1, 2.3→2 는 **가정**이다.

확인 전에 모은 데이터가 버려지지 않도록, 실제로 보낸 정수를 파일명(`fopt1`)과 CSV 열
(`freq_option`), run json 에 함께 남긴다. 대응이 반대로 밝혀져도 라벨만 바꾸면 된다.
run json 에 `freq_option_mapping_confirmed: false` 를 박아 두어 나중에 이 데이터가
어떤 상태에서 모였는지 알 수 있게 했다. 화면에도 같은 경고를 띄운다.

## 데이터 정합성
결과를 평범한 var 로 주고받으면 BLE 스레드↔코루틴 간 가시성이 보장되지 않아 Channel
을 쓴다. 그리고 **회차마다 보내기 전에 채널을 비운다** — 직전 회차가 시간초과된 뒤
뒤늦게 도착한 결과가 남아 있으면 이번 회차 데이터로 잘못 기록된다.

한 회차가 3초 안에 안 오면 실패로 세고 다음으로 넘어간다. 한 번 막혔다고 전체가
멈추면 채워 둔 방광을 버리게 된다. 실패 수는 화면과 run json 에 남는다.

## 검증
빌드 통과. **실기기 확인은 아직 못 했다** — 폰이 절전 상태로 들어가 화면을 못 띄웠고,
프로브 연결 상태의 측정 루프는 전혀 돌려보지 못했다. 내일 임상 전에 반드시 한 번
돌려봐야 한다(6조합 × 20회 = 120측정 · 약 2~3분 예상).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-02 16:53:47 +09:00
parent 5f40896bee
commit 53fd07c342
6 changed files with 580 additions and 0 deletions
@@ -17,6 +17,7 @@ enum class AppScreen {
VOIDING_DIARY, REMINDER_SETTINGS,
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
CLINICAL_LIVE, // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
HOSPITAL_MODE, // ★ Dev 모드 한정 — 병원 임상 측정 (주파수×cycle 자동 순회)
FIRMWARE_UPDATE // ★ 2026-08-03 (cloud-mvp 672e679 이식) — VBT 프로브 firmware DFU
}
@@ -207,6 +207,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
onBack = { appState.backToMonitoring() }
)
AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState)
AppScreen.HOSPITAL_MODE -> com.medithings.vesiscan.ui.views.clinical.HospitalModeView(appState)
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(appState)
AppScreen.FIRMWARE_UPDATE -> com.medithings.vesiscan.firmware.ui.FirmwareUpdateScreen(
onBack = { appState.currentScreen = AppScreen.HOME }
@@ -0,0 +1,67 @@
/*
* HospitalProtocol — 병원 임상 측정 프로토콜 정의.
*
* ## 무엇을 하는 모드인가
* 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도 하나만** 고르고
* [측정 시작]을 누른다. 그 뒤로는 앱이 주파수 2종 × cycle 3종 = **6조합을 자동으로
* 순회**하며 각 조합마다 6채널을 n회 측정하고, 조합별로 파일 하나씩 저장한다.
*
* 사람이 개입하는 지점을 방광 채움 하나로 줄인 것이 핵심이다 — 조합을 손으로 바꾸면
* 반드시 빠뜨리거나 잘못 기록한다. 6조합 × n회가 한 번의 버튼으로 끝난다.
*
* ## ⚠ 주파수 코드값이 아직 확인되지 않았다 (2026-09-02)
* 펌웨어에 보내는 `mpa?` 명령의 첫 인자는 **정수**인데, 그 정수와 실제 MHz 의 대응이
* 코드에도 `docs/BLE_PROTOCOL_REFERENCE.md` 에도 적혀 있지 않다. 기존 코드는 항상
* `freqOption = 2` 만 쓰고 있었다(PlacementGuideView · MeasurementService).
*
* 그래서 [ProbeFrequency.freqOption] 값은 **가정**이다. 펌웨어팀 확인 후 이 표 한 줄만
* 고치면 된다.
*
* 확인 전에 모은 데이터가 버려지지 않도록, 저장 파일명과 meta 에 **실제로 보낸 정수**를
* `fopt{N}` 으로 함께 남긴다. 대응이 반대로 밝혀져도 라벨만 바꿔 되살릴 수 있다.
*/
package com.medithings.vesiscan.models
/** 프로브 송신 주파수. [freqOption] 이 `mpa?` 의 첫 인자로 나간다. */
enum class ProbeFrequency(val label: String, val freqOption: Int) {
// ⚠ freqOption 값은 펌웨어팀 확인 전의 가정이다. 위 KDoc 참고.
F_1_8("1.8", 1),
F_2_3("2.3", 2), // 기존 코드가 늘 쓰던 값이 2 다.
;
/** 파일명에 쓸 표기 — MHz 와 실제 보낸 정수를 함께 남긴다. */
val fileTag: String get() = "${label}MHz-fopt$freqOption"
}
/** 버스트 cycle 수. `mpa?` 의 둘째 인자로 그대로 나간다. */
enum class ProbeCycle(val cycles: Int) {
C3(3), C5(5), C7(7);
val fileTag: String get() = "c$cycles"
}
/**
* 방광 채움 정도 — 간호사가 채워 둔 값을 조작자가 고른다.
*
* 앱이 재는 값이 아니라 **사람이 아는 값**이다. 이 모드의 목적이 "채움 정도가 알려진
* 상태에서 6채널 원신호를 모으는 것"이라, 이 값이 곧 정답 라벨이 된다.
*/
enum class BladderFill(val percent: Int) {
P0(0), P20(20), P40(40), P60(60), P80(80), P100(100);
val label: String get() = "$percent%"
val fileTag: String get() = "%03dpct".format(percent)
}
/**
* 한 번의 [측정 시작]이 도는 조합 목록 — 주파수 2 × cycle 3 = 6.
*
* 순서를 고정해 둔다. 매번 같은 순서로 돌아야 나중에 파일을 비교할 때 조건이 섞이지
* 않는다. 주파수를 바깥 고리에 둔 이유는 주파수 전환이 cycle 전환보다 프로브에 더 큰
* 변화라, 한 주파수 안에서 cycle 셋을 몰아 끝내는 편이 안정적이기 때문이다.
*/
val HOSPITAL_COMBINATIONS: List<Pair<ProbeFrequency, ProbeCycle>> =
ProbeFrequency.entries.flatMap { f -> ProbeCycle.entries.map { c -> f to c } }
/** 한 조합당 기본 반복 횟수. 화면에서 바꿀 수 있다. */
const val HOSPITAL_DEFAULT_REPEATS = 20
@@ -0,0 +1,152 @@
/*
* HospitalRunStore — 병원 임상 모드의 측정 파일 저장.
*
* ## 저장 위치
* Downloads/VesiScan_Hospital/{날짜_환자}/{조합}.csv
*
* 기존 임상 R&D 모드(`VesiScan_Sessions/`)와 폴더를 나눈다. 둘은 목적도 파일 구조도
* 다르고, 병원에서 받은 데이터를 R&D 세션과 섞어 두면 나중에 어느 쪽인지 구분하려고
* 파일을 하나씩 열어 봐야 한다.
*
* ## 파일명에 조건을 전부 적는다
* 2026-09-03_홍길동_Supine_040pct_1.8MHz-fopt1_c3.csv
*
* 폴더 구조로만 구분하면 파일 하나를 옮기는 순간 조건을 잃는다. 임상 데이터는 나중에
* 모아서 다른 사람이 분석하므로, **파일 하나만 봐도 조건을 알 수 있어야 한다.**
* `fopt1` 은 그때 실제로 펌웨어에 보낸 정수다 — 주파수 대응이 나중에 바뀌어도
* 이 값이 있으면 라벨을 되살릴 수 있다(HospitalProtocol KDoc 참고).
*
* ## CSV 형식
* 기존 [AdcCsvLogger] 와 같은 열 구성을 쓴다(scan_id · timestamp · 채널 · s0~s99).
* 분석 스크립트를 새로 만들지 않아도 되도록 맞춘 것이고, 앞쪽에 조건 열을 더했다.
*/
package com.medithings.vesiscan.services
import android.content.Context
import android.os.Environment
import com.medithings.vesiscan.models.BladderFill
import com.medithings.vesiscan.models.ClinicalPosture
import com.medithings.vesiscan.models.ProbeCycle
import com.medithings.vesiscan.models.ProbeFrequency
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object HospitalRunStore {
private const val ROOT = "VesiScan_Hospital"
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val stampFmt = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
/** 파일명에 못 쓰는 글자를 지운다. 환자명이 자유 입력이라 반드시 거친다. */
private fun safe(raw: String): String =
raw.trim().replace(Regex("""[^\p{L}\p{N}_-]"""), "-").take(40).ifBlank { "unknown" }
private fun downloads(): File =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
/** 한 환자·하루 단위 폴더. 같은 날 같은 환자를 여러 번 재면 파일이 이 안에 쌓인다. */
fun runDir(patient: String, startedAt: Date): File {
val dir = File(File(downloads(), ROOT), "${dayFmt.format(startedAt)}_${safe(patient)}")
if (!dir.exists()) dir.mkdirs()
return dir
}
/**
* 한 조합(주파수·cycle)의 파일. 이미 있으면 이어 쓴다 —
* 같은 조건을 다시 재는 것은 정상적인 재측정이라 덮어쓰지 않는다.
*/
fun combinationFile(
dir: File,
patient: String,
posture: ClinicalPosture,
fill: BladderFill,
freq: ProbeFrequency,
cycle: ProbeCycle,
startedAt: Date,
): File = File(
dir,
listOf(
dayFmt.format(startedAt),
safe(patient),
posture.label,
fill.fileTag,
freq.fileTag,
cycle.fileTag,
).joinToString("_") + ".csv"
)
private const val HEADER_PREFIX =
"scan_id,timestamp,patient,posture,fill_pct,freq_mhz,freq_option,cycles,repeat_idx," +
"device,firmware_version,channel"
/**
* 6채널 한 번을 기록한다. [channels] 는 채널 순으로 정렬된 100 샘플 버퍼 6개.
*
* 실패해도 예외를 던지지 않는다 — 측정 도중 파일 오류로 루프 전체가 멈추면 이미
* 채워 둔 방광을 버리게 된다. 대신 false 를 돌려주어 화면이 실패 횟수를 셀 수 있다.
*/
fun appendMeasurement(
file: File,
scanId: Int,
patient: String,
posture: ClinicalPosture,
fill: BladderFill,
freq: ProbeFrequency,
cycle: ProbeCycle,
repeatIdx: Int,
deviceName: String,
firmwareVersion: String,
channels: List<List<UShort>>,
): Boolean = try {
val isNew = !file.exists() || file.length() == 0L
java.io.FileWriter(file, !isNew).buffered().use { w ->
if (isNew) {
w.write(buildString {
append(HEADER_PREFIX)
for (i in 0 until 100) append(",s$i")
})
w.newLine()
}
val meta = listOf(
scanId.toString(),
stampFmt.format(Date()),
safe(patient),
posture.label,
fill.percent.toString(),
freq.label,
freq.freqOption.toString(),
cycle.cycles.toString(),
repeatIdx.toString(),
deviceName,
firmwareVersion,
).joinToString(",")
for ((chIdx, buffer) in channels.withIndex()) {
w.write(buildString {
append(meta); append(",CH"); append(chIdx)
for (s in buffer) { append(','); append(s.toInt()) }
for (i in buffer.size until 100) append(',')
})
w.newLine()
}
}
true
} catch (_: Exception) {
false
}
/**
* 실행 요약. 파일명에 다 적혀 있지만, **무엇을 재려 했는지**(계획)와 **실제로 몇 개가
* 남았는지**(결과)가 다를 수 있어 따로 남긴다. 중간에 연결이 끊기면 그 차이가 곧
* 데이터 신뢰도다.
*/
fun writeSummary(dir: File, json: JSONObject, startedAt: Date) {
runCatching {
File(dir, "run_${SimpleDateFormat("HHmmss", Locale.US).format(startedAt)}.json")
.writeText(json.toString(2), Charsets.UTF_8)
}
}
}
@@ -0,0 +1,345 @@
/*
* HospitalModeView — 병원 임상 측정 모드.
*
* ## 조작자가 하는 일은 하나다
* 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도만** 고르고
* [측정 시작]을 누른다. 그 뒤로 주파수 2 × cycle 3 = 6조합을 앱이 자동으로 순회하며
* 각 조합마다 n회(기본 20) 측정하고 조합별 파일로 저장한다.
*
* 조합을 손으로 바꾸게 두면 반드시 빠뜨리거나 잘못 기록한다. 이미 채워 둔 방광은
* 다시 만들 수 없으므로, 그 자리에서 놓친 조합은 그날 데이터에서 영영 빈다.
*
* ## 측정 한 번의 흐름
* sendPiezoPowerOn(freqOption, cycles) ← 조합 진입 시 1회
* sendMtb() → piezoCollector.onComplete ← n회 반복
*
* `sendMtb` 는 6채널+IMU 를 한 번에 받는 명령이고, 완료는 콜백으로 온다. 그래서 루프는
* "보내고 기다린다"가 아니라 **콜백이 올 때마다 다음 것을 보내는** 구조다. 응답이
* 안 오면 [MEASURE_TIMEOUT_MS] 뒤에 그 회차를 실패로 세고 다음으로 넘어간다 —
* 한 번 막혔다고 전체가 멈추면 채워 둔 방광을 버리게 된다.
*/
package com.medithings.vesiscan.ui.views.clinical
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
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.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.AppScreen
import com.medithings.vesiscan.AppState
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.models.*
import com.medithings.vesiscan.services.AdcCsvLogger
import com.medithings.vesiscan.services.HospitalRunStore
import com.medithings.vesiscan.ui.theme.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.withTimeoutOrNull
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.Date
/** 한 회차 응답을 이만큼 기다린다. 넘으면 실패로 세고 다음 회차로 넘어간다. */
private const val MEASURE_TIMEOUT_MS = 3_000L
/** 조합을 바꾼 뒤 프로브가 새 설정으로 안정될 때까지. */
private const val COMBINATION_SETTLE_MS = 800L
/** 회차 사이 간격 — 기존 임상 라이브(600ms)와 같은 값. */
private const val REPEAT_GAP_MS = 600L
@Composable
fun HospitalModeView(appState: AppState) {
val context = LocalContext.current
val bleManager = remember { BleManager.getInstance(context) }
val isConnected by bleManager.isConnected
val deviceName = bleManager.connectedDeviceName.value
var patient by remember { mutableStateOf("") }
var posture by remember { mutableStateOf(ClinicalPosture.SUPINE) }
var fill by remember { mutableStateOf(BladderFill.P0) }
var repeatsText by remember { mutableStateOf(HOSPITAL_DEFAULT_REPEATS.toString()) }
var running by remember { mutableStateOf(false) }
var comboIdx by remember { mutableIntStateOf(0) }
var repeatIdx by remember { mutableIntStateOf(0) }
var okCount by remember { mutableIntStateOf(0) }
var failCount by remember { mutableIntStateOf(0) }
var lastFile by remember { mutableStateOf<String?>(null) }
val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS
val canStart = isConnected && patient.isNotBlank() && !running
// ── 측정 루프 ────────────────────────────────────────────────────────
LaunchedEffect(running) {
if (!running) return@LaunchedEffect
val startedAt = Date()
val dir = HospitalRunStore.runDir(patient, startedAt)
val fw = bleManager.firmwareVersion.value
val results = JSONArray()
okCount = 0; failCount = 0
// 6채널 완료를 코루틴에서 받는 통로. 콜백은 BLE 스레드에서 오므로 평범한 var 로
// 주고받으면 가시성이 보장되지 않는다 — Channel 로 넘긴다.
//
// `onComplete` 는 **채널 하나**마다 오고, 6채널이 다 모이면
// `onMultiChannelComplete` 가 한 번 온다 — 후자를 써야 한 회차가 된다.
val inbox = kotlinx.coroutines.channels.Channel<List<com.medithings.vesiscan.ble.PiezoChannelData>>(
kotlinx.coroutines.channels.Channel.CONFLATED)
val prevOnComplete = bleManager.piezoCollector.onMultiChannelComplete
bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) }
try {
HOSPITAL_COMBINATIONS.forEachIndexed { ci, (freq, cycle) ->
if (!running) return@forEachIndexed
comboIdx = ci
val file = HospitalRunStore.combinationFile(
dir, patient, posture, fill, freq, cycle, startedAt)
// 조합 진입 — 프로브에 주파수·cycle 을 실어 보낸다.
bleManager.sendPiezoPowerOn(freqOption = freq.freqOption, cycles = cycle.cycles)
delay(COMBINATION_SETTLE_MS)
var ok = 0; var fail = 0
for (r in 1..repeats) {
if (!running) break
repeatIdx = r
// 직전 회차가 시간초과된 뒤 뒤늦게 도착한 결과가 남아 있을 수 있다.
// 그대로 두면 **이번 회차의 데이터로 잘못 기록된다** — 보내기 전에 비운다.
while (inbox.tryReceive().isSuccess) { /* drain */ }
bleManager.sendMtb()
val got = withTimeoutOrNull(MEASURE_TIMEOUT_MS) { inbox.receive() }
if (got == null) {
fail++; failCount++
} else {
val raw = got.sortedBy { it.channel }.map { it.buffer }
val saved = HospitalRunStore.appendMeasurement(
file = file,
scanId = AdcCsvLogger.nextScanId(),
patient = patient, posture = posture, fill = fill,
freq = freq, cycle = cycle, repeatIdx = r,
deviceName = deviceName ?: "", firmwareVersion = fw,
channels = raw,
)
if (saved) { ok++; okCount++ } else { fail++; failCount++ }
lastFile = file.name
}
delay(REPEAT_GAP_MS)
}
results.put(JSONObject().apply {
put("freq_mhz", freq.label)
put("freq_option", freq.freqOption)
put("cycles", cycle.cycles)
put("planned", repeats)
put("saved", ok)
put("failed", fail)
put("file", file.name)
})
}
} finally {
bleManager.piezoCollector.onMultiChannelComplete = prevOnComplete
inbox.close()
HospitalRunStore.writeSummary(dir, JSONObject().apply {
put("patient", patient)
put("posture", posture.label)
put("fill_pct", fill.percent)
put("repeats", repeats)
put("device", deviceName ?: "")
put("firmware_version", fw)
// ⚠ 주파수 코드값이 확인 전이라는 사실을 데이터와 함께 남긴다.
put("freq_option_mapping_confirmed", false)
put("combinations", results)
}, startedAt)
running = false
}
}
Column(
modifier = Modifier.fillMaxSize().background(MlBackground)
.verticalScroll(rememberScrollState())
) {
Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = {
if (!running) appState.currentScreen = AppScreen.CLINICAL_HOME
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
}
Text("병원 임상 측정", fontSize = 18.sp, fontWeight = FontWeight.Bold)
}
Column(Modifier.padding(horizontal = 16.dp)) {
StatusRow(isConnected, deviceName)
Spacer(Modifier.height(12.dp))
OutlinedTextField(
value = patient,
onValueChange = { if (!running) patient = it },
label = { Text("환자명") },
singleLine = true,
enabled = !running,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(12.dp))
Label("자세")
ChipRow(ClinicalPosture.entries, posture, { posture = it }, !running) { it.label }
Spacer(Modifier.height(12.dp))
Label("방광 채움 정도 — 간호사가 채워 둔 값")
ChipRow(BladderFill.entries, fill, { fill = it }, !running) { it.label }
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("6조합 × ${repeats}회 = 총 ${6 * repeats}회 측정") },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(16.dp))
if (running) {
val (f, c) = HOSPITAL_COMBINATIONS[comboIdx]
ProgressPanel(
comboIdx = comboIdx, repeatIdx = repeatIdx, repeats = repeats,
freqLabel = f.label, cycles = c.cycles,
ok = okCount, fail = failCount, lastFile = lastFile,
)
Spacer(Modifier.height(12.dp))
Button(
onClick = { running = false },
modifier = Modifier.fillMaxWidth().height(52.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD32F2F)),
) { Text("중지", color = Color.White, fontWeight = FontWeight.Bold) }
} else {
Button(
onClick = { comboIdx = 0; repeatIdx = 0; running = true },
enabled = canStart,
modifier = Modifier.fillMaxWidth().height(56.dp),
shape = RoundedCornerShape(14.dp),
) { Text("측정 시작", fontSize = 17.sp, fontWeight = FontWeight.Bold) }
if (!isConnected) Hint("기기를 먼저 연결해 주세요.")
else if (patient.isBlank()) Hint("환자명을 입력해 주세요.")
if (okCount > 0 || failCount > 0) {
Spacer(Modifier.height(12.dp))
Text("직전 실행 — 저장 $okCount · 실패 $failCount",
fontSize = 13.sp, color = MlSecondaryText)
}
}
Spacer(Modifier.height(16.dp))
Text(
"저장 위치: Downloads/VesiScan_Hospital/",
fontSize = 11.sp, color = MlSecondaryText,
)
// 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의
// 한계를 알고 있어야 한다.
Text(
"⚠ 주파수 코드값(mpa 첫 인자)은 펌웨어팀 확인 전입니다. " +
"파일에 실제 전송값(fopt)을 함께 기록하므로 나중에 라벨을 고칠 수 있습니다.",
fontSize = 11.sp, color = Color(0xFFB26A00),
modifier = Modifier.padding(top = 6.dp, bottom = 24.dp),
)
}
}
}
@Composable private fun Label(text: String) =
Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText)
@Composable private fun Hint(text: String) =
Text(text, fontSize = 12.sp, color = MlSecondaryText, modifier = Modifier.padding(top = 6.dp))
@Composable
private fun StatusRow(connected: Boolean, deviceName: String?) {
val color = if (connected) Color(0xFF4CAF50) else Color(0xFFD32F2F)
Text(
if (connected) "연결됨 · ${deviceName ?: ""}" else "연결 안 됨",
fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = color,
modifier = Modifier
.background(color.copy(alpha = 0.12f), RoundedCornerShape(8.dp))
.padding(horizontal = 10.dp, vertical = 5.dp),
)
}
@Composable
private fun <T> ChipRow(
items: List<T>, selected: T, onSelect: (T) -> Unit, enabled: Boolean, label: (T) -> String,
) {
Row(
Modifier.fillMaxWidth().padding(top = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
items.forEach { item ->
val on = item == selected
Box(
Modifier
.weight(1f)
.background(
if (on) MlPrimary else Color.White,
RoundedCornerShape(10.dp),
)
.clickable(enabled = enabled) { onSelect(item) }
.padding(vertical = 10.dp),
contentAlignment = Alignment.Center,
) {
Text(
label(item), fontSize = 13.sp,
fontWeight = if (on) FontWeight.Bold else FontWeight.Normal,
color = if (on) Color.White else MlSecondaryText,
)
}
}
}
}
@Composable
private fun ProgressPanel(
comboIdx: Int, repeatIdx: Int, repeats: Int,
freqLabel: String, cycles: Int, ok: Int, fail: Int, lastFile: String?,
) {
val total = HOSPITAL_COMBINATIONS.size * repeats
val done = comboIdx * repeats + repeatIdx
Column(
Modifier.fillMaxWidth()
.background(Color.White, RoundedCornerShape(12.dp))
.padding(14.dp)
) {
Text("측정 중 — ${comboIdx + 1}/${HOSPITAL_COMBINATIONS.size} 조합",
fontSize = 15.sp, fontWeight = FontWeight.Bold)
Spacer(Modifier.height(4.dp))
Text("$freqLabel MHz · cycle $cycles · $repeatIdx/$repeats 회",
fontSize = 13.sp, color = MlSecondaryText)
Spacer(Modifier.height(10.dp))
LinearProgressIndicator(
progress = { if (total > 0) done.toFloat() / total else 0f },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Text("저장 $ok · 실패 $fail", fontSize = 12.sp, color = MlSecondaryText)
lastFile?.let {
Text(it, fontSize = 10.sp, color = MlSecondaryText.copy(alpha = 0.7f))
}
}
}
@@ -158,6 +158,20 @@ fun HomeView(appState: AppState) {
) {
Text(stringResource(R.string.home_clinical_mode_dev), color = Color.White, fontWeight = FontWeight.SemiBold)
}
// 병원 임상 측정 — R&D 임상 모드와 목적이 다르다. 저쪽은 알고리즘 개발용
// (정렬 단계·자유 라벨), 이쪽은 정해진 프로토콜을 빠짐없이 도는 것이 목적이다.
Button(
onClick = { appState.currentScreen = com.medithings.vesiscan.AppScreen.HOSPITAL_MODE },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp)
.padding(bottom = 8.dp)
.height(48.dp),
shape = RoundedCornerShape(14.dp),
colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF0B7D6B))
) {
Text("병원 임상 측정", color = Color.White, fontWeight = FontWeight.SemiBold)
}
}
// 2026-08-03 (cloud-mvp 672e679 이식): 프로브 firmware 업데이트 진입점.
TextButton(