From 7fa18ec05a86004f16a8bb66acbea7861c10c4ef Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 9 Jun 2026 17:26:37 +0900 Subject: [PATCH] =?UTF-8?q?Clinical:=20SubjectType=20=EC=95=84=EB=9E=98=20?= =?UTF-8?q?Subject(optional=20=EC=9E=90=EC=9C=A0=20=EC=8B=9D=EB=B3=84?= =?UTF-8?q?=EC=9E=90)=20=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 회의 추가 결정: Phantom/Human/ETC 카테고리 안에서도 표본을 구분하기 위한 자유 텍스트 식별자가 필요. 예: "500mL phantom", "S001", "small phantom v2". ClinicalSession 모델: - val subject: String = "" (optional, 기본값 빈 문자열) - folderName: prefix로 포함 (비어있으면 생략) 예: PHANTOM-500mL_VBT26050202_SUPINE_ALIGN_0CM_2026-06-09_15-30-00 예 (subject 없음): PHANTOM_VBT26050202_SUPINE_ALIGN_0CM_2026-06-09_15-30-00 - 폴더명 안전 문자만 (특수문자 → '-'), testId 형식과 일관성 유지 ClinicalSessionStore: - lastSubject: String 추가 (다음 측정 자동 prefill) - startMeasurement에서 저장 JSON 저장: - meta.json + measurement.json의 meta 블록에 "subject" 필드 추가 - 비어있으면 필드 자체 생략 (기존 호환) labdb payload: - params.subject 필드 추가 (subject_type과 함께) UI (ClinicalHomeView): - SubjectType segmented 아래에 OutlinedTextField - 라벨: 'Subject (선택) — 예: 500mL, S001' - IME action Done + onDone hide - 마지막 입력 유지 (lastSubject prefill) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../models/ClinicalSession.kt | 23 ++++++++++++++++--- .../services/ClinicalSessionStore.kt | 4 ++++ .../services/labdb/LabdbUploader.kt | 1 + .../ui/views/clinical/ClinicalHomeView.kt | 19 +++++++++++++++ 4 files changed, 44 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt b/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt index b73a30d..40ad200 100644 --- a/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt +++ b/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt @@ -47,8 +47,12 @@ data class ClinicalSession( val step: ClinicalStep, val examiner: String = "", val notes: String = "", - // 측정 대상 분류 (회의 결정: Phantom/Human/ETC 버튼 — subject ID 자유 입력 대체) + // 측정 대상 분류 (회의: Phantom/Human/ETC 버튼) val subjectType: SubjectType = SubjectType.PHANTOM, + // 카테고리 내 추가 식별자 (회의 추가 결정: optional) + // 예: "500mL", "S001", "small phantom v2" 등 + // 비어있으면 folderName에 포함 안 됨 + val subject: String = "", val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL) val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm) val startedAt: Long = System.currentTimeMillis(), @@ -56,9 +60,22 @@ data class ClinicalSession( ) { private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) - /** "PHANTOM_VBT26050202_SUPINE_ALIGN_0CM_2026-05-28_14-30-15" */ + /** "PHANTOM-500mL_VBT26050202_SUPINE_ALIGN_0CM_2026-05-28_14-30-15" + * subject 비어있으면 "PHANTOM_VBT...". 폴더명 안전한 문자만 (특수문자 → '-'). */ val folderName: String - get() = "${subjectType.name}_${deviceName}_${posture.name}_${step.name}_${folderFmt.format(Date(startedAt))}" + get() = buildString { + append(subjectType.name) + if (subject.isNotBlank()) { + append('-') + // 폴더명/파일명 안전 문자만 — testId 형식과도 일관 (^[A-Za-z0-9_-]$) + append(subject.replace(Regex("[^A-Za-z0-9_-]"), "-")) + } + append('_') + append(deviceName); append('_') + append(posture.name); append('_') + append(step.name); append('_') + append(folderFmt.format(Date(startedAt))) + } /** clinical_label 컬럼/필드 값 (라벨링 자동) */ val label: String diff --git a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt index de85cd5..6523fa5 100644 --- a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt +++ b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt @@ -53,6 +53,7 @@ object ClinicalSessionStore { var lastNotes: String = "" var lastSubjectType: com.example.medilightv2android.models.SubjectType = com.example.medilightv2android.models.SubjectType.PHANTOM + var lastSubject: String = "" // 카테고리 내 식별자 (예: "500mL", "S001") var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식) var lastAbdomenThicknessMm: String = "" // 회의 결정: Step/Posture 마지막 선택 유지 — 매번 0cm로 리셋되는 불편 제거 @@ -69,6 +70,7 @@ object ClinicalSessionStore { lastExaminer = session.examiner lastNotes = session.notes lastSubjectType = session.subjectType + lastSubject = session.subject lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: "" lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: "" lastPosture = session.posture @@ -124,6 +126,7 @@ object ClinicalSessionStore { put("label", s.label) // 측정 대상 분류 (회의 결정: Phantom/Human/ETC) put("subject_type", s.subjectType.name) + if (s.subject.isNotBlank()) put("subject", s.subject) // 임상 시험 참값 (외부 측정기, vesiscan_test 프로토콜) s.trueVolumeMl?.let { put("true_volume_ml", it) } s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) } @@ -196,6 +199,7 @@ object ClinicalSessionStore { val meta = JSONObject().apply { put("data_type", DATA_TYPE) put("subject_type", s.subjectType.name) + if (s.subject.isNotBlank()) put("subject", s.subject) put("device_name", s.deviceName) put("posture", s.posture.name) put("step", s.step.name) diff --git a/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbUploader.kt b/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbUploader.kt index 62ce6e6..d2cabd0 100644 --- a/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbUploader.kt +++ b/app/src/main/java/com/example/medilightv2android/services/labdb/LabdbUploader.kt @@ -107,6 +107,7 @@ object LabdbUploader { put("is_alignment", meta.optBoolean("is_alignment")) put("examiner", meta.optString("examiner")) meta.optString("subject_type").takeIf { it.isNotBlank() }?.let { put("subject_type", it) } + meta.optString("subject").takeIf { it.isNotBlank() }?.let { put("subject", it) } if (meta.has("true_volume_ml")) put("true_volume_ml", meta.optDouble("true_volume_ml")) if (meta.has("abdomen_thickness_mm")) put("abdomen_thickness_mm", meta.optDouble("abdomen_thickness_mm")) meta.optString("firmware_version").takeIf { it.isNotBlank() }?.let { put("firmware_version", it) } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt index d42412c..125ccbf 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt @@ -66,6 +66,8 @@ fun ClinicalHomeView(appState: AppState) { var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) } // 측정 대상 분류 (회의 결정: Phantom/Human/ETC 버튼) var subjectType by remember { mutableStateOf(ClinicalSessionStore.lastSubjectType) } + // 카테고리 내 추가 식별자 (예: "500mL", "S001") — optional + var subject by remember { mutableStateOf(ClinicalSessionStore.lastSubject) } var trueVolText by remember { mutableStateOf(ClinicalSessionStore.lastTrueVolumeMl) } var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) } @@ -383,6 +385,22 @@ fun ClinicalHomeView(appState: AppState) { selected = subjectType, onSelect = { subjectType = it } ) { it.label } + Spacer(modifier = Modifier.height(8.dp)) + // 카테고리 내 추가 식별자 — 예: "500mL", "S001" + val subjectFocus = androidx.compose.ui.platform.LocalSoftwareKeyboardController.current + OutlinedTextField( + value = subject, + onValueChange = { subject = it }, + label = { Text("Subject (선택) — 예: 500mL, S001") }, + singleLine = true, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + imeAction = androidx.compose.ui.text.input.ImeAction.Done + ), + keyboardActions = androidx.compose.foundation.text.KeyboardActions( + onDone = { subjectFocus?.hide() } + ), + modifier = Modifier.fillMaxWidth() + ) Spacer(modifier = Modifier.height(12.dp)) Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( @@ -456,6 +474,7 @@ fun ClinicalHomeView(appState: AppState) { examiner = examiner.trim(), notes = notes.trim(), subjectType = subjectType, + subject = subject.trim(), trueVolumeMl = trueVolText.toDoubleOrNull(), abdomenThicknessMm = abdomenText.toDoubleOrNull(), )