diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index 4b46296..6dc103f 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -115,6 +115,11 @@ class AppState(private val context: Context) { fun deviceConnected() { isDeviceConnected = true + // 임상 모드에서 BLE 연결 시 ClinicalHome으로 복귀 (Personalization 우회) + if (com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow) { + currentScreen = AppScreen.CLINICAL_HOME + return + } currentScreen = if (sensorMode == SensorMode.NIRS) { AppScreen.NIRS_CALIBRATION } else { diff --git a/app/src/main/java/com/example/medilightv2android/MainActivity.kt b/app/src/main/java/com/example/medilightv2android/MainActivity.kt index 5043927..3d33f77 100644 --- a/app/src/main/java/com/example/medilightv2android/MainActivity.kt +++ b/app/src/main/java/com/example/medilightv2android/MainActivity.kt @@ -95,18 +95,28 @@ fun MedilightApp(modifier: Modifier = Modifier) { && appState.currentScreen != AppScreen.PIN_ENTRY) { when (appState.currentScreen) { // 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring - AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.HOME + // DeviceScan Back: 임상 흐름이면 ClinicalHome으로, 아니면 HOME + AppScreen.DEVICE_SCAN -> + appState.currentScreen = + if (com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow) + AppScreen.CLINICAL_HOME + else AppScreen.HOME AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN - // 임상 모드면 측정 후 ClinicalHome으로 복귀 + // 임상 모드 측정 중이면 endMeasurement → ClinicalHome 복귀 AppScreen.PLACEMENT_GUIDE -> - if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null) + if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null) { + com.example.medilightv2android.services.ClinicalSessionStore.endMeasurement(context) appState.currentScreen = AppScreen.CLINICAL_HOME - else appState.backFromPlacementGuide() + } else appState.backFromPlacementGuide() AppScreen.PIEZO_MONITORING -> - if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null) + if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null) { + com.example.medilightv2android.services.ClinicalSessionStore.endMeasurement(context) appState.currentScreen = AppScreen.CLINICAL_HOME - else appState.currentScreen = AppScreen.PLACEMENT_GUIDE - AppScreen.CLINICAL_HOME -> appState.currentScreen = AppScreen.HOME + } else appState.currentScreen = AppScreen.PLACEMENT_GUIDE + AppScreen.CLINICAL_HOME -> { + com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow = false + appState.currentScreen = AppScreen.HOME + } // 모니터링 하위 화면 → 모니터링 AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING 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 8797de3..031d46c 100644 --- a/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt +++ b/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt @@ -5,10 +5,18 @@ import java.util.Date import java.util.Locale /** - * 임상 R&D 측정 세션 모델. + * 임상 R&D 측정 — "한 측정 단위" 모델. * - * 한 피험자의 한 측정 세션 = 3 devices × 3 postures × 6 steps = 54 measurements. - * Dev 모드에서만 접근 가능. 세션 폴더로 모든 로그가 자동 라우팅됨. + * 한 ClinicalSession = 한 라벨 = 한 폴더 = 한 측정 사이클. + * 사용자가 BLE 연결 후 매번 라벨(Subject/Device/Posture/Step)을 입력하고 + * 측정을 진행하면, 측정 종료 시점에 폴더가 finalize됨. + * + * 데이터 구조 (한 측정당): + * Downloads/VesiScan_Sessions/{folderName}/ + * ├─ meta.json + * ├─ adc.csv + * ├─ placements.jsonl (alignment) or events.jsonl (BV) + * └─ ble.log */ enum class ClinicalDevice(val label: String) { @@ -20,63 +28,36 @@ enum class ClinicalPosture(val label: String) { } /** - * 한 condition 내 측정 단계. + * 측정 단계. * - ALIGN_0CM ~ ALIGN_4CM: 정렬모드 알고리즘 개발용 (치골 위 0~4cm) * - BV: cradle 부착 후 본측정 (방광 추정 알고리즘 개발용) */ -enum class ClinicalStep(val label: String, val folder: String, val isAlignment: Boolean) { - ALIGN_0CM("0 cm", "align_0cm", true), - ALIGN_1CM("1 cm", "align_1cm", true), - ALIGN_2CM("2 cm", "align_2cm", true), - ALIGN_3CM("3 cm", "align_3cm", true), - ALIGN_4CM("4 cm", "align_4cm", true), - BV("BV", "bv", false) +enum class ClinicalStep(val label: String, val isAlignment: Boolean) { + ALIGN_0CM("0 cm", true), + ALIGN_1CM("1 cm", true), + ALIGN_2CM("2 cm", true), + ALIGN_3CM("3 cm", true), + ALIGN_4CM("4 cm", true), + BV("BV", false) } data class ClinicalSession( - val sessionId: String, // "S001" — 사용자 입력 라벨 (익명) + val subjectId: String, // "S001" + val device: ClinicalDevice, + val posture: ClinicalPosture, + val step: ClinicalStep, val examiner: String = "", val notes: String = "", val startedAt: Long = System.currentTimeMillis(), - var endedAt: Long? = null, - var currentDevice: ClinicalDevice? = null, - var currentPosture: ClinicalPosture? = null, - var currentStep: ClinicalStep? = null, - val completedKeys: MutableSet = mutableSetOf() + var endedAt: Long? = null ) { private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) - /** Downloads/VesiScan_Sessions/{folderName}/ 의 폴더명 */ + /** "S001_A_SUPINE_ALIGN_0CM_2026-05-28_14-30-15" */ val folderName: String - get() = "${sessionId}_${folderFmt.format(Date(startedAt))}" + get() = "${subjectId}_${device.name}_${posture.name}_${step.name}_${folderFmt.format(Date(startedAt))}" - /** "A_SUPINE_ALIGN_0CM" — 라벨/완료 추적용 키 */ - fun stepKey(d: ClinicalDevice, p: ClinicalPosture, s: ClinicalStep): String = - "${d.name}_${p.name}_${s.name}" - - fun currentStepKey(): String? { - val d = currentDevice ?: return null - val p = currentPosture ?: return null - val s = currentStep ?: return null - return stepKey(d, p, s) - } - - fun markCompleted(d: ClinicalDevice, p: ClinicalPosture, s: ClinicalStep) { - completedKeys.add(stepKey(d, p, s)) - } - - fun markCurrentCompleted() { - currentStepKey()?.let { completedKeys.add(it) } - } - - fun isCompleted(d: ClinicalDevice, p: ClinicalPosture, s: ClinicalStep): Boolean = - stepKey(d, p, s) in completedKeys - - fun conditionCompletedCount(d: ClinicalDevice, p: ClinicalPosture): Int = - ClinicalStep.values().count { isCompleted(d, p, it) } - - val totalSteps: Int = - ClinicalDevice.values().size * ClinicalPosture.values().size * ClinicalStep.values().size - - val completedCount: Int get() = completedKeys.size + /** clinical_label 컬럼/필드 값 (라벨링 자동) */ + val label: String + get() = "${subjectId}_${device.name}_${posture.name}_${step.name}" } diff --git a/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt b/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt index 77a7520..cb58825 100644 --- a/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt +++ b/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt @@ -40,9 +40,10 @@ object AdcCsvLogger { * 일반 모드: 기존 세션 파일 사용. */ private fun getFile(): File { - val clinicalStepDir = ClinicalSessionStore.stepLogDir() - if (clinicalStepDir != null) { - return File(clinicalStepDir, "adc.csv") + val clinicalDir = ClinicalSessionStore.currentLogDir() + if (clinicalDir != null) { + clinicalDir.mkdirs() + return File(clinicalDir, "adc.csv") } if (sessionFile == null) newSession() return sessionFile!! diff --git a/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt b/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt index 3e2a44a..3236c0a 100644 --- a/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt +++ b/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt @@ -85,21 +85,22 @@ class BleDebugLogger private constructor(private val context: Context) { // 임상 모드: 세션 step 폴더의 ble.log로 라우팅 (step 전환 시 새 파일) // 일반 모드: Downloads/VesiScan_BLE_*.log (BLE 연결 1회당 1파일) private var liveLogFile: File? = null - private var liveLogStepLabel: String = "" // 임상 모드 step 변경 감지 + private var liveLogLabel: String = "" // 임상 모드 측정 단위 변경 감지 private fun ensureLiveLogFile(): File { - val clinicalStepDir = ClinicalSessionStore.stepLogDir() - if (clinicalStepDir != null) { + val clinicalDir = ClinicalSessionStore.currentLogDir() + if (clinicalDir != null) { + clinicalDir.mkdirs() val currentLabel = ClinicalSessionStore.currentLabel() - // step이 바뀌었으면 새 파일로 전환 - if (liveLogFile == null || liveLogStepLabel != currentLabel) { - liveLogFile = File(clinicalStepDir, "ble.log") - liveLogStepLabel = currentLabel + // 측정 단위가 바뀌었으면 새 파일로 전환 + if (liveLogFile == null || liveLogLabel != currentLabel) { + liveLogFile = File(clinicalDir, "ble.log") + liveLogLabel = currentLabel } - } else if (liveLogFile == null || liveLogStepLabel.isNotEmpty()) { + } else if (liveLogFile == null || liveLogLabel.isNotEmpty()) { // 일반 모드 진입 또는 임상 모드 종료 val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS) liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log") - liveLogStepLabel = "" + liveLogLabel = "" } return liveLogFile!! } 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 c035a71..0e28dc8 100644 --- a/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt +++ b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt @@ -3,89 +3,83 @@ package com.example.medilightv2android.services import android.content.Context import android.os.Environment import com.example.medilightv2android.models.ClinicalSession -import com.example.medilightv2android.models.ClinicalStep import org.json.JSONObject import java.io.File -import java.io.FileOutputStream import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream /** - * 임상 세션 전역 store. Logger들이 세션 존재 여부와 폴더 경로를 조회. + * 임상 측정 단위(한 측정 = 한 라벨 = 한 폴더) 전역 store. * - * - currentSession != null 이면 임상 모드 진입 상태 - * - 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)는 - * sessionLogDir()이 반환하는 폴더로 저장 라우팅 - * - endSession()에서 자동 zip + meta.json 마무리 + * 사용자가 BLE 연결 후 라벨을 입력하고 startMeasurement()를 호출하면 + * Downloads/VesiScan_Sessions/{folderName}/ 폴더가 생성되고 meta.json이 작성됨. + * 이후 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)가 + * 해당 폴더로 자동 라우팅. endMeasurement()에서 meta.json이 finalize됨. + * + * zip은 사용하지 않음 — 폴더 자체가 분석 단위. */ object ClinicalSessionStore { + /** ClinicalHome 화면에 진입한 상태인지. BLE 연결을 위한 DeviceScan 우회/Back 라우팅에 사용. */ + @Volatile + var inClinicalFlow: Boolean = false + @Volatile var currentSession: ClinicalSession? = null private set - fun startSession(session: ClinicalSession) { + /** 마지막으로 종료된 측정 (UI에서 "Saved: ..." 표시용) */ + @Volatile + var lastFinalizedFolder: File? = null + private set + + /** 다음 측정 시 기본값으로 자동 채울 사용자 입력 (subjectId/examiner/notes) */ + var lastSubjectId: String = "" + var lastExaminer: String = "" + var lastNotes: String = "" + + fun startMeasurement(context: Context, session: ClinicalSession) { currentSession = session - // 세션 루트 폴더 생성 - sessionRootDir()?.mkdirs() + lastSubjectId = session.subjectId + lastExaminer = session.examiner + lastNotes = session.notes + currentLogDir()?.mkdirs() + writeMeta(context) } - fun updateCurrent( - device: com.example.medilightv2android.models.ClinicalDevice? = null, - posture: com.example.medilightv2android.models.ClinicalPosture? = null, - step: ClinicalStep? = null - ) { - val s = currentSession ?: return - device?.let { s.currentDevice = it } - posture?.let { s.currentPosture = it } - step?.let { s.currentStep = it } - } - - fun markCurrentCompleted() { - currentSession?.markCurrentCompleted() - } - - /** 세션 루트: Downloads/VesiScan_Sessions/{folderName}/ */ - fun sessionRootDir(): File? { + /** 현재 측정의 폴더 (= 모든 로거의 저장 디렉토리). */ + fun currentLogDir(): File? { val s = currentSession ?: return null val downloads = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) return File(File(downloads, "VesiScan_Sessions"), s.folderName) } - /** 현재 step의 로그 폴더: {sessionRoot}/{device}_{posture}/{step.folder}/ */ - fun stepLogDir(): File? { - val s = currentSession ?: return null - val root = sessionRootDir() ?: return null - val d = s.currentDevice?.name ?: "UNK" - val p = s.currentPosture?.name ?: "UNK" - val st = s.currentStep?.folder ?: "UNK" - return File(File(root, "${d}_${p}"), st).apply { mkdirs() } - } + /** clinical_label 컬럼/필드용. 비활성 시 "" */ + fun currentLabel(): String = currentSession?.label ?: "" - /** "S001_A_SUPINE_ALIGN_0CM" — CSV row 라벨 / JSONL 필드용 */ - fun currentLabel(): String { - val s = currentSession ?: return "" - val d = s.currentDevice?.name ?: "?" - val p = s.currentPosture?.name ?: "?" - val st = s.currentStep?.name ?: "?" - return "${s.sessionId}_${d}_${p}_${st}" - } - - /** meta.json 작성/갱신 */ fun writeMeta(context: Context) { val s = currentSession ?: return - val root = sessionRootDir() ?: return - root.mkdirs() + val dir = currentLogDir() ?: return + dir.mkdirs() val meta = JSONObject().apply { - put("session_id", s.sessionId) + put("subject_id", s.subjectId) + put("device", s.device.name) + put("posture", s.posture.name) + put("step", s.step.name) + put("is_alignment", s.step.isAlignment) put("examiner", s.examiner) put("notes", s.notes) + put("label", s.label) put("started_at", s.startedAt) - put("started_at_iso", SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt))) - s.endedAt?.let { put("ended_at", it) } + put("started_at_iso", + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt))) + s.endedAt?.let { + put("ended_at", it) + put("ended_at_iso", + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(it))) + put("duration_ms", it - s.startedAt) + } put("app_version_name", try { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown" } catch (_: Exception) { "unknown" }) @@ -97,48 +91,23 @@ object ClinicalSessionStore { put("device_manufacturer", android.os.Build.MANUFACTURER) put("android_release", android.os.Build.VERSION.RELEASE) put("android_sdk", android.os.Build.VERSION.SDK_INT) - put("completed_steps", org.json.JSONArray().apply { - s.completedKeys.forEach { put(it) } - }) - put("completed_count", s.completedCount) - put("total_steps", s.totalSteps) } - File(root, "meta.json").writeText(meta.toString(2)) + File(dir, "meta.json").writeText(meta.toString(2)) } - /** 세션 종료: endedAt 기록 → meta.json 갱신 → zip 생성 */ - fun endSession(context: Context): File? { + /** 측정 종료: endedAt 기록 → meta.json 갱신 → currentSession=null */ + fun endMeasurement(context: Context): File? { val s = currentSession ?: return null s.endedAt = System.currentTimeMillis() writeMeta(context) - val root = sessionRootDir() ?: return null - if (!root.exists()) return null - val zipFile = File(root.parentFile, "${s.folderName}.zip") - try { - ZipOutputStream(FileOutputStream(zipFile)).use { zos -> - zipDirectory(root, root.name, zos) - } - } catch (_: Exception) { return null } + val dir = currentLogDir() + lastFinalizedFolder = dir currentSession = null - return zipFile + return dir } - /** 세션 폐기 (zip 없이 currentSession만 비움) */ - fun discardSession() { + /** 측정 폐기 (메타/파일은 그대로 두되 store만 비움) */ + fun discardMeasurement() { currentSession = null } - - private fun zipDirectory(dir: File, prefix: String, zos: ZipOutputStream) { - val files = dir.listFiles() ?: return - for (f in files) { - val entryName = "$prefix/${f.name}" - if (f.isDirectory) { - zipDirectory(f, entryName, zos) - } else { - zos.putNextEntry(ZipEntry(entryName)) - f.inputStream().use { it.copyTo(zos) } - zos.closeEntry() - } - } - } } diff --git a/app/src/main/java/com/example/medilightv2android/services/MeasurementLogService.kt b/app/src/main/java/com/example/medilightv2android/services/MeasurementLogService.kt index b3d97a6..dfd2f16 100644 --- a/app/src/main/java/com/example/medilightv2android/services/MeasurementLogService.kt +++ b/app/src/main/java/com/example/medilightv2android/services/MeasurementLogService.kt @@ -169,9 +169,10 @@ class MeasurementLogService private constructor(private val context: Context) { private fun appendLog(entry: JSONObject, clinicalFileName: String = "events.jsonl") { try { val line = entry.toString() + "\n" - val clinicalStepDir = ClinicalSessionStore.stepLogDir() - val file = if (clinicalStepDir != null) { - File(clinicalStepDir, clinicalFileName) + val clinicalDir = ClinicalSessionStore.currentLogDir() + val file = if (clinicalDir != null) { + clinicalDir.mkdirs() + File(clinicalDir, clinicalFileName) } else { File(logDir, dateFormatter.format(Date()) + ".jsonl") } 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 a209b51..46ac9de 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment @@ -22,38 +23,46 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.medilightv2android.AppScreen import com.example.medilightv2android.AppState +import com.example.medilightv2android.ble.BleManager import com.example.medilightv2android.models.* import com.example.medilightv2android.services.ClinicalSessionStore import com.example.medilightv2android.ui.theme.* /** - * Clinical R&D 측정 세션 화면. + * Clinical R&D 측정 화면 — "한 측정 = 한 라벨 = 한 폴더" 모델. * - * 진행: 세션 시작 → device/posture 선택 → 5 alignment 위치 + BV 측정 → 다음 condition → ... → 세션 종료(zip) - * - * 데이터 라우팅: ClinicalSessionStore.currentSession 이 active일 때, - * AdcCsvLogger / MeasurementLogService / BleDebugLogger가 모두 세션 step 폴더로 자동 저장. + * 흐름: + * 1. BLE 연결 (미연결 시 Connect 버튼 → DeviceScan) + * 2. 측정 라벨 입력 (Subject/Device/Posture/Step/Examiner/Notes) + * 3. Start Measurement → PlacementGuide(alignment) or PiezoMonitoring(BV) + * 4. 측정 후 Back → 자동 endMeasurement → 폴더 finalize + * 5. 다음 측정 라벨 입력 또는 종료 */ @Composable fun ClinicalHomeView(appState: AppState) { val context = LocalContext.current - var sessionTick by remember { mutableIntStateOf(0) } // 세션 상태 변경 시 강제 recompose - val session = ClinicalSessionStore.currentSession + val bleManager = remember { BleManager.getInstance(context) } + val isConnected by bleManager.isConnected - if (session == null) { - ClinicalSessionStartCard( - onStart = { id, ex, notes -> - ClinicalSessionStore.startSession( - ClinicalSession(sessionId = id, examiner = ex, notes = notes) - ) - ClinicalSessionStore.writeMeta(context) - sessionTick++ - }, - onCancel = { appState.currentScreen = AppScreen.HOME } - ) - return + // 진입 시 flag set, 화면 떠날 때 unset (DEVICE_SCAN/측정화면 라우팅용) + DisposableEffect(Unit) { + ClinicalSessionStore.inClinicalFlow = true + onDispose { + // 측정 진행을 위해 다른 화면(DEVICE_SCAN/PLACEMENT_GUIDE/PIEZO_MONITORING)으로 갈 땐 flag 유지. + // HOME으로 복귀 시에만 unset → MainActivity의 HOME 진입 시 처리. + } } + // 라벨 입력 state — 마지막 입력값 기억 (Store에서 자동 채움) + var subjectId by remember { mutableStateOf(ClinicalSessionStore.lastSubjectId) } + var device by remember { mutableStateOf(ClinicalDevice.A) } + var posture by remember { mutableStateOf(ClinicalPosture.SUPINE) } + var step by remember { mutableStateOf(ClinicalStep.ALIGN_0CM) } + var examiner by remember { mutableStateOf(ClinicalSessionStore.lastExaminer) } + var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) } + + val lastFolder = ClinicalSessionStore.lastFinalizedFolder + Column( modifier = Modifier .fillMaxSize() @@ -63,75 +72,67 @@ fun ClinicalHomeView(appState: AppState) { ) { // Top bar Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = { appState.currentScreen = AppScreen.HOME }) { + IconButton(onClick = { + ClinicalSessionStore.inClinicalFlow = false + appState.currentScreen = AppScreen.HOME + }) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary) } Spacer(modifier = Modifier.width(8.dp)) - Column { - Text("Clinical Session — ${session.sessionId}", - fontSize = 20.sp, fontWeight = FontWeight.Bold) - Text("Examiner: ${session.examiner.ifEmpty { "(none)" }}", - fontSize = 12.sp, color = MlSecondaryText) - } - Spacer(modifier = Modifier.weight(1f)) - Text("${session.completedCount} / ${session.totalSteps}", - fontSize = 14.sp, fontWeight = FontWeight.Bold, color = MlPrimary) + Text("Clinical Measurement", fontSize = 20.sp, fontWeight = FontWeight.Bold) } Spacer(modifier = Modifier.height(16.dp)) - // 9-conditions grid (3 devices × 3 postures) - Text("Conditions (3 devices × 3 postures)", - fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.height(8.dp)) + // BLE 연결 상태 Card( - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors(containerColor = Color.White) + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors( + containerColor = if (isConnected) Color(0xFF4CAF50).copy(alpha = 0.15f) + else Color(0xFFFF9800).copy(alpha = 0.15f) + ) ) { - Column(modifier = Modifier.padding(8.dp)) { - // Header row - Row { - Box(modifier = Modifier.width(80.dp)) {} - for (p in ClinicalPosture.values()) { - Box( - modifier = Modifier.weight(1f).padding(4.dp), - contentAlignment = Alignment.Center - ) { - Text(p.label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) - } + Row( + modifier = Modifier.padding(12.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + if (isConnected) { + Icon(Icons.Default.CheckCircle, "Connected", + tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Column(modifier = Modifier.weight(1f)) { + Text("BLE Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp) + Text(bleManager.connectedDeviceName.value, + fontSize = 11.sp, color = MlSecondaryText) } + } else { + Text("BLE Not Connected", fontWeight = FontWeight.Bold, fontSize = 14.sp, + color = Color(0xFFFF9800), modifier = Modifier.weight(1f)) + Button( + onClick = { appState.currentScreen = AppScreen.DEVICE_SCAN }, + colors = ButtonDefaults.buttonColors(containerColor = MlPrimary) + ) { Text("Connect", color = Color.White) } } - for (d in ClinicalDevice.values()) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text(d.label, fontSize = 12.sp, fontWeight = FontWeight.SemiBold, - modifier = Modifier.width(80.dp).padding(4.dp)) - for (p in ClinicalPosture.values()) { - val done = session.conditionCompletedCount(d, p) - val isSelected = session.currentDevice == d && session.currentPosture == p - val bg = when { - isSelected -> MlPrimary - done == ClinicalStep.values().size -> Color(0xFF4CAF50).copy(alpha = 0.3f) - done > 0 -> Color(0xFFFFC107).copy(alpha = 0.3f) - else -> Color.Gray.copy(alpha = 0.1f) - } - val fg = if (isSelected) Color.White else Color.Black - Box( - modifier = Modifier - .weight(1f) - .padding(4.dp) - .clip(RoundedCornerShape(8.dp)) - .background(bg) - .clickable { - ClinicalSessionStore.updateCurrent(device = d, posture = p, step = null) - sessionTick++ - } - .padding(vertical = 12.dp), - contentAlignment = Alignment.Center - ) { - Text("$done / ${ClinicalStep.values().size}", - fontSize = 12.sp, fontWeight = FontWeight.Bold, color = fg) - } - } + } + } + + // 직전 측정 결과 표시 + if (lastFolder != null) { + Spacer(modifier = Modifier.height(12.dp)) + Card( + shape = RoundedCornerShape(10.dp), + colors = CardDefaults.cardColors(containerColor = Color(0xFF4CAF50).copy(alpha = 0.08f)) + ) { + Row( + modifier = Modifier.padding(12.dp).fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Icon(Icons.Default.CheckCircle, null, + tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp)) + Spacer(modifier = Modifier.width(8.dp)) + Column { + Text("Last saved", fontSize = 11.sp, color = MlSecondaryText) + Text(lastFolder.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) } } } @@ -139,147 +140,28 @@ fun ClinicalHomeView(appState: AppState) { Spacer(modifier = Modifier.height(16.dp)) - // Steps for current condition - val d = session.currentDevice - val p = session.currentPosture - if (d != null && p != null) { - Text("Steps — ${d.label} × ${p.label}", - fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.height(8.dp)) - for (step in ClinicalStep.values()) { - val completed = session.isCompleted(d, p, step) - Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp) - .clickable { - ClinicalSessionStore.updateCurrent(step = step) - ClinicalSessionStore.writeMeta(context) - // 진입: alignment → PlacementGuide / BV → PiezoMonitoring - // 단 BLE 연결이 우선. 미연결 시 DeviceScan으로. - appState.currentScreen = if (appState.isDeviceConnected) { - if (step.isAlignment) AppScreen.PLACEMENT_GUIDE - else AppScreen.PIEZO_MONITORING - } else AppScreen.DEVICE_SCAN - sessionTick++ - }, - shape = RoundedCornerShape(10.dp), - colors = CardDefaults.cardColors( - containerColor = if (completed) Color(0xFF4CAF50).copy(alpha = 0.15f) else Color.White - ) - ) { - Row( - modifier = Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically - ) { - if (completed) { - Icon(Icons.Default.CheckCircle, "Done", - tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp)) - Spacer(modifier = Modifier.width(8.dp)) - } else { - Box(modifier = Modifier.size(20.dp)) {} - Spacer(modifier = Modifier.width(8.dp)) - } - Text(step.label, fontSize = 14.sp, fontWeight = FontWeight.SemiBold) - Spacer(modifier = Modifier.weight(1f)) - Text( - if (step.isAlignment) "Alignment 측정" else "BV 본측정", - fontSize = 12.sp, color = MlSecondaryText - ) - } - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - // Mark complete 버튼 (현재 step만) - val currentStep = session.currentStep - if (currentStep != null && !session.isCompleted(d, p, currentStep)) { - Button( - onClick = { - session.markCompleted(d, p, currentStep) - ClinicalSessionStore.writeMeta(context) - Toast.makeText(context, - "${d.label} × ${p.label} × ${currentStep.label} marked complete", - Toast.LENGTH_SHORT).show() - sessionTick++ - }, - modifier = Modifier.fillMaxWidth(), - colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF4CAF50)) - ) { - Text("Mark Current Step Complete (${currentStep.label})", - color = Color.White, fontWeight = FontWeight.Bold) - } - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // End session - var showEndConfirm by remember { mutableStateOf(false) } - Button( - onClick = { showEndConfirm = true }, - modifier = Modifier.fillMaxWidth().height(50.dp), - colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.8f)) - ) { - Text("End Session → Create ZIP", color = Color.White, fontWeight = FontWeight.Bold) - } - if (showEndConfirm) { - AlertDialog( - onDismissRequest = { showEndConfirm = false }, - title = { Text("End Session") }, - text = { Text("Session ${session.sessionId} 종료하고 ZIP을 만들까요?\n완료: ${session.completedCount}/${session.totalSteps}") }, - confirmButton = { - TextButton(onClick = { - val zip = ClinicalSessionStore.endSession(context) - showEndConfirm = false - if (zip != null) { - Toast.makeText(context, - "Saved: ${zip.absolutePath}", - Toast.LENGTH_LONG).show() - } - appState.currentScreen = AppScreen.HOME - }) { Text("End + Zip", color = Color.Red) } - }, - dismissButton = { - TextButton(onClick = { showEndConfirm = false }) { Text("Cancel") } - } - ) - } - + // 라벨 입력 폼 + Text("Measurement Label", fontWeight = FontWeight.Bold, fontSize = 16.sp) Spacer(modifier = Modifier.height(8.dp)) - Text( - "Saved to: Downloads/VesiScan_Sessions/${session.folderName}/", - fontSize = 10.sp, color = MlSecondaryText - ) - } -} -@Composable -private fun ClinicalSessionStartCard( - onStart: (id: String, examiner: String, notes: String) -> Unit, - onCancel: () -> Unit -) { - var sessionId by remember { mutableStateOf("") } - var examiner by remember { mutableStateOf("") } - var notes by remember { mutableStateOf("") } - Column( - modifier = Modifier - .fillMaxSize() - .background(MlBackground) - .padding(24.dp), - verticalArrangement = Arrangement.Center - ) { - Text("Start Clinical Session", fontSize = 24.sp, fontWeight = FontWeight.Bold) - Spacer(modifier = Modifier.height(24.dp)) OutlinedTextField( - value = sessionId, - onValueChange = { sessionId = it }, + value = subjectId, + onValueChange = { subjectId = it }, label = { Text("Subject ID (예: S001)") }, singleLine = true, modifier = Modifier.fillMaxWidth() ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(12.dp)) + + LabeledSegmented("Device", ClinicalDevice.values().toList(), device, { device = it }) { it.label } + Spacer(modifier = Modifier.height(12.dp)) + + LabeledSegmented("Posture", ClinicalPosture.values().toList(), posture, { posture = it }) { it.label } + Spacer(modifier = Modifier.height(12.dp)) + + LabeledSegmented("Step", ClinicalStep.values().toList(), step, { step = it }) { it.label } + Spacer(modifier = Modifier.height(12.dp)) + OutlinedTextField( value = examiner, onValueChange = { examiner = it }, @@ -288,25 +170,99 @@ private fun ClinicalSessionStartCard( modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( value = notes, onValueChange = { notes = it }, label = { Text("Notes (선택)") }, - modifier = Modifier.fillMaxWidth().height(100.dp) + modifier = Modifier.fillMaxWidth().height(80.dp) ) - Spacer(modifier = Modifier.height(24.dp)) - Row(modifier = Modifier.fillMaxWidth()) { - OutlinedButton( - onClick = onCancel, - modifier = Modifier.weight(1f) - ) { Text("Cancel") } + + Spacer(modifier = Modifier.height(20.dp)) + + // 라벨 preview + val previewLabel = if (subjectId.isNotBlank()) + "${subjectId}_${device.name}_${posture.name}_${step.name}" + else "(Subject ID 입력 필요)" + Text("Label: $previewLabel", + fontSize = 12.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold) + + Spacer(modifier = Modifier.height(8.dp)) + + // Start Measurement + Button( + onClick = { + ClinicalSessionStore.startMeasurement( + context, + ClinicalSession( + subjectId = subjectId.trim(), + device = device, + posture = posture, + step = step, + examiner = examiner.trim(), + notes = notes.trim() + ) + ) + appState.currentScreen = if (step.isAlignment) AppScreen.PLACEMENT_GUIDE + else AppScreen.PIEZO_MONITORING + }, + enabled = isConnected && subjectId.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(56.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = MlPrimary) + ) { + Icon(Icons.Default.PlayArrow, null, tint = Color.White) Spacer(modifier = Modifier.width(8.dp)) - Button( - onClick = { if (sessionId.isNotBlank()) onStart(sessionId.trim(), examiner.trim(), notes.trim()) }, - enabled = sessionId.isNotBlank(), - modifier = Modifier.weight(1f), - colors = ButtonDefaults.buttonColors(containerColor = MlPrimary) - ) { Text("Start", color = Color.White, fontWeight = FontWeight.Bold) } + Text("Start Measurement", color = Color.White, + fontWeight = FontWeight.Bold, fontSize = 16.sp) + } + + if (!isConnected) { + Spacer(modifier = Modifier.height(4.dp)) + Text("→ BLE 연결 후 활성화", fontSize = 11.sp, color = Color(0xFFFF9800)) + } else if (subjectId.isBlank()) { + Spacer(modifier = Modifier.height(4.dp)) + Text("→ Subject ID 입력 필요", fontSize = 11.sp, color = Color(0xFFFF9800)) + } + + Spacer(modifier = Modifier.height(24.dp)) + Text("저장 위치: Downloads/VesiScan_Sessions/", + fontSize = 10.sp, color = MlSecondaryText) + } +} + +@Composable +private fun LabeledSegmented( + label: String, + options: List, + selected: T, + onSelect: (T) -> Unit, + labelOf: (T) -> String +) { + Column { + Text(label, fontSize = 12.sp, color = MlSecondaryText, fontWeight = FontWeight.SemiBold) + Spacer(modifier = Modifier.height(4.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + for (opt in options) { + val isSel = opt == selected + Box( + modifier = Modifier + .weight(1f) + .padding(end = 4.dp) + .clip(RoundedCornerShape(8.dp)) + .background(if (isSel) MlPrimary else Color.Gray.copy(alpha = 0.12f)) + .clickable { onSelect(opt) } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Text( + labelOf(opt), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = if (isSel) Color.White else Color.Black + ) + } + } } } }