Clinical: SubjectType 아래 Subject(optional 자유 식별자) 필드 추가
회의 추가 결정: 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) <noreply@anthropic.com>
This commit is contained in:
@@ -47,8 +47,12 @@ data class ClinicalSession(
|
|||||||
val step: ClinicalStep,
|
val step: ClinicalStep,
|
||||||
val examiner: String = "",
|
val examiner: String = "",
|
||||||
val notes: String = "",
|
val notes: String = "",
|
||||||
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC 버튼 — subject ID 자유 입력 대체)
|
// 측정 대상 분류 (회의: Phantom/Human/ETC 버튼)
|
||||||
val subjectType: SubjectType = SubjectType.PHANTOM,
|
val subjectType: SubjectType = SubjectType.PHANTOM,
|
||||||
|
// 카테고리 내 추가 식별자 (회의 추가 결정: optional)
|
||||||
|
// 예: "500mL", "S001", "small phantom v2" 등
|
||||||
|
// 비어있으면 folderName에 포함 안 됨
|
||||||
|
val subject: String = "",
|
||||||
val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL)
|
val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL)
|
||||||
val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm)
|
val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm)
|
||||||
val startedAt: Long = System.currentTimeMillis(),
|
val startedAt: Long = System.currentTimeMillis(),
|
||||||
@@ -56,9 +60,22 @@ data class ClinicalSession(
|
|||||||
) {
|
) {
|
||||||
private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
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
|
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 컬럼/필드 값 (라벨링 자동) */
|
/** clinical_label 컬럼/필드 값 (라벨링 자동) */
|
||||||
val label: String
|
val label: String
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ object ClinicalSessionStore {
|
|||||||
var lastNotes: String = ""
|
var lastNotes: String = ""
|
||||||
var lastSubjectType: com.example.medilightv2android.models.SubjectType =
|
var lastSubjectType: com.example.medilightv2android.models.SubjectType =
|
||||||
com.example.medilightv2android.models.SubjectType.PHANTOM
|
com.example.medilightv2android.models.SubjectType.PHANTOM
|
||||||
|
var lastSubject: String = "" // 카테고리 내 식별자 (예: "500mL", "S001")
|
||||||
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
|
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
|
||||||
var lastAbdomenThicknessMm: String = ""
|
var lastAbdomenThicknessMm: String = ""
|
||||||
// 회의 결정: Step/Posture 마지막 선택 유지 — 매번 0cm로 리셋되는 불편 제거
|
// 회의 결정: Step/Posture 마지막 선택 유지 — 매번 0cm로 리셋되는 불편 제거
|
||||||
@@ -69,6 +70,7 @@ object ClinicalSessionStore {
|
|||||||
lastExaminer = session.examiner
|
lastExaminer = session.examiner
|
||||||
lastNotes = session.notes
|
lastNotes = session.notes
|
||||||
lastSubjectType = session.subjectType
|
lastSubjectType = session.subjectType
|
||||||
|
lastSubject = session.subject
|
||||||
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
|
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
|
||||||
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
|
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
|
||||||
lastPosture = session.posture
|
lastPosture = session.posture
|
||||||
@@ -124,6 +126,7 @@ object ClinicalSessionStore {
|
|||||||
put("label", s.label)
|
put("label", s.label)
|
||||||
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC)
|
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC)
|
||||||
put("subject_type", s.subjectType.name)
|
put("subject_type", s.subjectType.name)
|
||||||
|
if (s.subject.isNotBlank()) put("subject", s.subject)
|
||||||
// 임상 시험 참값 (외부 측정기, vesiscan_test 프로토콜)
|
// 임상 시험 참값 (외부 측정기, vesiscan_test 프로토콜)
|
||||||
s.trueVolumeMl?.let { put("true_volume_ml", it) }
|
s.trueVolumeMl?.let { put("true_volume_ml", it) }
|
||||||
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
|
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
|
||||||
@@ -196,6 +199,7 @@ object ClinicalSessionStore {
|
|||||||
val meta = JSONObject().apply {
|
val meta = JSONObject().apply {
|
||||||
put("data_type", DATA_TYPE)
|
put("data_type", DATA_TYPE)
|
||||||
put("subject_type", s.subjectType.name)
|
put("subject_type", s.subjectType.name)
|
||||||
|
if (s.subject.isNotBlank()) put("subject", s.subject)
|
||||||
put("device_name", s.deviceName)
|
put("device_name", s.deviceName)
|
||||||
put("posture", s.posture.name)
|
put("posture", s.posture.name)
|
||||||
put("step", s.step.name)
|
put("step", s.step.name)
|
||||||
|
|||||||
@@ -107,6 +107,7 @@ object LabdbUploader {
|
|||||||
put("is_alignment", meta.optBoolean("is_alignment"))
|
put("is_alignment", meta.optBoolean("is_alignment"))
|
||||||
put("examiner", meta.optString("examiner"))
|
put("examiner", meta.optString("examiner"))
|
||||||
meta.optString("subject_type").takeIf { it.isNotBlank() }?.let { put("subject_type", it) }
|
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("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"))
|
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) }
|
meta.optString("firmware_version").takeIf { it.isNotBlank() }?.let { put("firmware_version", it) }
|
||||||
|
|||||||
+19
@@ -66,6 +66,8 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) }
|
var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) }
|
||||||
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC 버튼)
|
// 측정 대상 분류 (회의 결정: Phantom/Human/ETC 버튼)
|
||||||
var subjectType by remember { mutableStateOf(ClinicalSessionStore.lastSubjectType) }
|
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 trueVolText by remember { mutableStateOf(ClinicalSessionStore.lastTrueVolumeMl) }
|
||||||
var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) }
|
var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) }
|
||||||
|
|
||||||
@@ -383,6 +385,22 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
selected = subjectType,
|
selected = subjectType,
|
||||||
onSelect = { subjectType = it }
|
onSelect = { subjectType = it }
|
||||||
) { it.label }
|
) { 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))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
@@ -456,6 +474,7 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
examiner = examiner.trim(),
|
examiner = examiner.trim(),
|
||||||
notes = notes.trim(),
|
notes = notes.trim(),
|
||||||
subjectType = subjectType,
|
subjectType = subjectType,
|
||||||
|
subject = subject.trim(),
|
||||||
trueVolumeMl = trueVolText.toDoubleOrNull(),
|
trueVolumeMl = trueVolText.toDoubleOrNull(),
|
||||||
abdomenThicknessMm = abdomenText.toDoubleOrNull(),
|
abdomenThicknessMm = abdomenText.toDoubleOrNull(),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user