From 53fd07c342e0f0d67f655ea8bb1d220639bbd33e Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 2 Sep 2026 16:53:47 +0900 Subject: [PATCH] =?UTF-8?q?feat(clinical):=20=EB=B3=91=EC=9B=90=20?= =?UTF-8?q?=EC=9E=84=EC=83=81=20=EC=B8=A1=EC=A0=95=20=EB=AA=A8=EB=93=9C=20?= =?UTF-8?q?=E2=80=94=20=EC=A3=BC=ED=8C=8C=EC=88=98=C3=97cycle=206=EC=A1=B0?= =?UTF-8?q?=ED=95=A9=20=EC=9E=90=EB=8F=99=20=EC=88=9C=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 조작자가 하는 일은 하나다 간호사가 방광을 정해진 정도까지 채워 두면, 조작자는 **그 채움 정도만** 고르고 [측정 시작]을 누른다. 이후 주파수 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 --- .../java/com/medithings/vesiscan/AppState.kt | 1 + .../com/medithings/vesiscan/MainActivity.kt | 1 + .../vesiscan/models/HospitalProtocol.kt | 67 ++++ .../vesiscan/services/HospitalRunStore.kt | 152 ++++++++ .../ui/views/clinical/HospitalModeView.kt | 345 ++++++++++++++++++ .../vesiscan/ui/views/home/HomeView.kt | 14 + 6 files changed, 580 insertions(+) create mode 100644 app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt create mode 100644 app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt create mode 100644 app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt diff --git a/app/src/main/java/com/medithings/vesiscan/AppState.kt b/app/src/main/java/com/medithings/vesiscan/AppState.kt index e4adaa4..ec1a87c 100644 --- a/app/src/main/java/com/medithings/vesiscan/AppState.kt +++ b/app/src/main/java/com/medithings/vesiscan/AppState.kt @@ -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 } diff --git a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt index c8d5c98..543780d 100644 --- a/app/src/main/java/com/medithings/vesiscan/MainActivity.kt +++ b/app/src/main/java/com/medithings/vesiscan/MainActivity.kt @@ -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 } diff --git a/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt new file mode 100644 index 0000000..38e25f2 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/models/HospitalProtocol.kt @@ -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> = + ProbeFrequency.entries.flatMap { f -> ProbeCycle.entries.map { c -> f to c } } + +/** 한 조합당 기본 반복 횟수. 화면에서 바꿀 수 있다. */ +const val HOSPITAL_DEFAULT_REPEATS = 20 diff --git a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt new file mode 100644 index 0000000..9ec8985 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -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>, + ): 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) + } + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt new file mode 100644 index 0000000..bc563c7 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -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(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>( + 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 ChipRow( + items: List, 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)) + } + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt index c39fadf..080c316 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/home/HomeView.kt @@ -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(