diff --git a/app/src/main/java/com/example/medilightv2android/AppState.kt b/app/src/main/java/com/example/medilightv2android/AppState.kt index a01627a..4b46296 100644 --- a/app/src/main/java/com/example/medilightv2android/AppState.kt +++ b/app/src/main/java/com/example/medilightv2android/AppState.kt @@ -14,7 +14,8 @@ enum class AppScreen { ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN, NIRS_CALIBRATION, PIEZO_PERSONALIZATION, PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY, VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY, - VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS + VOIDING_DIARY, URINE_CAMERA, REMINDER_SETTINGS, + CLINICAL_HOME // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 } class AppState(private val context: Context) { diff --git a/app/src/main/java/com/example/medilightv2android/MainActivity.kt b/app/src/main/java/com/example/medilightv2android/MainActivity.kt index 677c570..5043927 100644 --- a/app/src/main/java/com/example/medilightv2android/MainActivity.kt +++ b/app/src/main/java/com/example/medilightv2android/MainActivity.kt @@ -39,6 +39,7 @@ import com.example.medilightv2android.ui.views.registration.RegistrationView import com.example.medilightv2android.ui.views.consent.ConsentView import com.example.medilightv2android.ui.views.reminder.ReminderSettingsView import com.example.medilightv2android.ui.views.sensorselect.SensorSelectView +import com.example.medilightv2android.ui.views.clinical.ClinicalHomeView class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { @@ -89,15 +90,23 @@ fun MedilightApp(modifier: Modifier = Modifier) { ) } - // 다른 화면에서 뒤로가기 = 바로 전 화면 + // 다른 화면에서 뒤로가기 = 바로 전 화면 BackHandler(enabled = appState.currentScreen != AppScreen.HOME && appState.currentScreen != AppScreen.PIN_ENTRY) { when (appState.currentScreen) { // 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.HOME AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN - AppScreen.PLACEMENT_GUIDE -> appState.backFromPlacementGuide() - AppScreen.PIEZO_MONITORING -> appState.currentScreen = AppScreen.PLACEMENT_GUIDE + // 임상 모드면 측정 후 ClinicalHome으로 복귀 + AppScreen.PLACEMENT_GUIDE -> + if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null) + appState.currentScreen = AppScreen.CLINICAL_HOME + else appState.backFromPlacementGuide() + AppScreen.PIEZO_MONITORING -> + if (com.example.medilightv2android.services.ClinicalSessionStore.currentSession != null) + appState.currentScreen = AppScreen.CLINICAL_HOME + else appState.currentScreen = AppScreen.PLACEMENT_GUIDE + AppScreen.CLINICAL_HOME -> appState.currentScreen = AppScreen.HOME // 모니터링 하위 화면 → 모니터링 AppScreen.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING AppScreen.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING @@ -144,6 +153,7 @@ fun MedilightApp(modifier: Modifier = Modifier) { AppScreen.REMINDER_SETTINGS -> ReminderSettingsView( onBack = { appState.backToMonitoring() } ) + AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState) } } } \ No newline at end of file diff --git a/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt b/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt new file mode 100644 index 0000000..8797de3 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/models/ClinicalSession.kt @@ -0,0 +1,82 @@ +package com.example.medilightv2android.models + +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * 임상 R&D 측정 세션 모델. + * + * 한 피험자의 한 측정 세션 = 3 devices × 3 postures × 6 steps = 54 measurements. + * Dev 모드에서만 접근 가능. 세션 폴더로 모든 로그가 자동 라우팅됨. + */ + +enum class ClinicalDevice(val label: String) { + A("Device A"), B("Device B"), C("Device C") +} + +enum class ClinicalPosture(val label: String) { + SUPINE("Supine"), SITTING("Sitting"), STANDING("Standing") +} + +/** + * 한 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) +} + +data class ClinicalSession( + val sessionId: String, // "S001" — 사용자 입력 라벨 (익명) + 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() +) { + private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US) + + /** Downloads/VesiScan_Sessions/{folderName}/ 의 폴더명 */ + val folderName: String + get() = "${sessionId}_${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 +} 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 7ea5702..77a7520 100644 --- a/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt +++ b/app/src/main/java/com/example/medilightv2android/services/AdcCsvLogger.kt @@ -35,7 +35,15 @@ object AdcCsvLogger { sessionFile = File(getDir(), "${sessionFmt.format(Date())}.csv") } + /** + * 임상 모드: ClinicalSessionStore에 활성 세션이 있으면 그 step 폴더의 adc.csv 사용. + * 일반 모드: 기존 세션 파일 사용. + */ private fun getFile(): File { + val clinicalStepDir = ClinicalSessionStore.stepLogDir() + if (clinicalStepDir != null) { + return File(clinicalStepDir, "adc.csv") + } if (sessionFile == null) newSession() return sessionFile!! } @@ -60,10 +68,12 @@ object AdcCsvLogger { val dpsStr = "%.3f".format(com.example.medilightv2android.managers.PiezoHW.distancePerSample) val sgStr = if (analyzer.useSgFilter) "ON" else "OFF" + val clinicalLabel = ClinicalSessionStore.currentLabel() // 일반 모드면 "" + java.io.FileWriter(file, !isNew).buffered().use { writer -> if (isNew) { val header = buildString { - append("scan_id,timestamp,volume_ml,lr_ratio,threshold,dps,sg_filter,channel") + append("scan_id,timestamp,clinical_label,volume_ml,lr_ratio,threshold,dps,sg_filter,channel") for (i in 0 until 100) append(",s$i") } writer.write(header) @@ -72,7 +82,7 @@ object AdcCsvLogger { for ((chIdx, buffer) in channels.withIndex()) { val row = buildString { - append("$scanCounter,$timestamp,$volStr,$lrStr,$thrStr,$dpsStr,$sgStr,CH$chIdx") + append("$scanCounter,$timestamp,$clinicalLabel,$volStr,$lrStr,$thrStr,$dpsStr,$sgStr,CH$chIdx") for (sample in buffer) { append(",${sample.toInt()}") } 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 1090778..3e2a44a 100644 --- a/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt +++ b/app/src/main/java/com/example/medilightv2android/services/BleDebugLogger.kt @@ -82,11 +82,24 @@ class BleDebugLogger private constructor(private val context: Context) { private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US) // 실시간 파일 기록 (화면 잠금/앱 종료에도 보존) + // 임상 모드: 세션 step 폴더의 ble.log로 라우팅 (step 전환 시 새 파일) + // 일반 모드: Downloads/VesiScan_BLE_*.log (BLE 연결 1회당 1파일) private var liveLogFile: File? = null + private var liveLogStepLabel: String = "" // 임상 모드 step 변경 감지 private fun ensureLiveLogFile(): File { - if (liveLogFile == null) { + val clinicalStepDir = ClinicalSessionStore.stepLogDir() + if (clinicalStepDir != null) { + val currentLabel = ClinicalSessionStore.currentLabel() + // step이 바뀌었으면 새 파일로 전환 + if (liveLogFile == null || liveLogStepLabel != currentLabel) { + liveLogFile = File(clinicalStepDir, "ble.log") + liveLogStepLabel = currentLabel + } + } else if (liveLogFile == null || liveLogStepLabel.isNotEmpty()) { + // 일반 모드 진입 또는 임상 모드 종료 val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS) liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log") + liveLogStepLabel = "" } 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 new file mode 100644 index 0000000..c035a71 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/services/ClinicalSessionStore.kt @@ -0,0 +1,144 @@ +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들이 세션 존재 여부와 폴더 경로를 조회. + * + * - currentSession != null 이면 임상 모드 진입 상태 + * - 모든 로거(AdcCsvLogger / MeasurementLogService / BleDebugLogger)는 + * sessionLogDir()이 반환하는 폴더로 저장 라우팅 + * - endSession()에서 자동 zip + meta.json 마무리 + */ +object ClinicalSessionStore { + + @Volatile + var currentSession: ClinicalSession? = null + private set + + fun startSession(session: ClinicalSession) { + currentSession = session + // 세션 루트 폴더 생성 + sessionRootDir()?.mkdirs() + } + + 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? { + 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() } + } + + /** "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 meta = JSONObject().apply { + put("session_id", s.sessionId) + put("examiner", s.examiner) + put("notes", s.notes) + 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("app_version_name", try { + context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown" + } catch (_: Exception) { "unknown" }) + put("app_version_code", try { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0).versionCode + } catch (_: Exception) { -1 }) + put("device_model", android.os.Build.MODEL) + 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)) + } + + /** 세션 종료: endedAt 기록 → meta.json 갱신 → zip 생성 */ + fun endSession(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 } + currentSession = null + return zipFile + } + + /** 세션 폐기 (zip 없이 currentSession만 비움) */ + fun discardSession() { + 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 71960cc..b3d97a6 100644 --- a/app/src/main/java/com/example/medilightv2android/services/MeasurementLogService.kt +++ b/app/src/main/java/com/example/medilightv2android/services/MeasurementLogService.kt @@ -17,14 +17,26 @@ import java.util.* * File location: vesiscan_logs/YYYY-MM-DD.jsonl * Each line = one measurement event (JSON) */ -class MeasurementLogService private constructor(context: Context) { +class MeasurementLogService private constructor(private val context: Context) { private val logDir: File private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.US) private val timestampFormatter = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.US) + private val appVersionName: String = try { + context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown" + } catch (_: Exception) { "unknown" } + private val appVersionCode: Int = try { + @Suppress("DEPRECATION") + context.packageManager.getPackageInfo(context.packageName, 0).versionCode + } catch (_: Exception) { -1 } init { - logDir = File(context.filesDir, "vesiscan_logs") + // Downloads/VesiScan_Events/ 로 이동 — 앱 삭제 시 데이터 손실 방지. + // 내부 저장소(filesDir)는 앱 uninstall 시 같이 사라지지만, 외부 Downloads는 유지됨. + val downloads = android.os.Environment.getExternalStoragePublicDirectory( + android.os.Environment.DIRECTORY_DOWNLOADS + ) + logDir = File(downloads, "VesiScan_Events") if (!logDir.exists()) logDir.mkdirs() } @@ -53,7 +65,10 @@ class MeasurementLogService private constructor(context: Context) { put("event", "measurement") put("timestamp", timestampFormatter.format(Date())) put("device", Build.MODEL) - put("app_version", getAppVersion()) + put("app_version", appVersionName) + put("app_version_code", appVersionCode) + val clinical = ClinicalSessionStore.currentLabel() + if (clinical.isNotEmpty()) put("clinical_label", clinical) } if (volumeMl != null) { @@ -86,10 +101,18 @@ class MeasurementLogService private constructor(context: Context) { /** * Log Placement Guide result. + * + * 임상 모드에서는 구조화된 placements.jsonl로 별도 라우팅. + * 일반 모드에서는 기존 events JSONL 묶음에 그대로. */ fun logPlacement( channelDetected: List, - direction: String + direction: String, + urineLens: List = emptyList(), + cv: Double = Double.NaN, + lrDev: Double = Double.NaN, + phase: String = "", + score: Int = -1 ) { val entry = JSONObject().apply { put("event", "placement") @@ -99,8 +122,18 @@ class MeasurementLogService private constructor(context: Context) { put("detected", arr) put("detected_count", channelDetected.count { it }) put("direction", direction) + if (urineLens.isNotEmpty()) { + put("urine_lens", JSONArray().apply { urineLens.forEach { put(it) } }) + put("urine_sum", urineLens.sum()) + } + if (!cv.isNaN()) put("cv", cv) + if (!lrDev.isNaN()) put("lr_dev", lrDev) + if (phase.isNotEmpty()) put("phase", phase) + if (score >= 0) put("score", score) + val clinical = ClinicalSessionStore.currentLabel() + if (clinical.isNotEmpty()) put("clinical_label", clinical) } - appendLog(entry) + appendLog(entry, "placements.jsonl") } /** @@ -129,11 +162,19 @@ class MeasurementLogService private constructor(context: Context) { // -- File Operations -- - private fun appendLog(entry: JSONObject) { + /** + * 임상 모드면 세션 step 폴더의 지정 파일에 추가. + * 일반 모드면 Downloads/VesiScan_Events/YYYY-MM-DD.jsonl 에 추가. + */ + private fun appendLog(entry: JSONObject, clinicalFileName: String = "events.jsonl") { try { val line = entry.toString() + "\n" - val fileName = dateFormatter.format(Date()) + ".jsonl" - val file = File(logDir, fileName) + val clinicalStepDir = ClinicalSessionStore.stepLogDir() + val file = if (clinicalStepDir != null) { + File(clinicalStepDir, clinicalFileName) + } else { + File(logDir, dateFormatter.format(Date()) + ".jsonl") + } file.appendText(line) } catch (e: Exception) { // Silently fail — logging should never crash the app @@ -173,8 +214,8 @@ class MeasurementLogService private constructor(context: Context) { val totalLogSize: Long get() = listLogFiles().sumOf { it.length() } - /** Purge logs older than [keepDays] days (default 90) */ - fun purgeOldLogs(keepDays: Int = 90) { + /** Purge logs older than [keepDays] days (default 365, R&D 데이터 장기 보관) */ + fun purgeOldLogs(keepDays: Int = 365) { val calendar = Calendar.getInstance() calendar.add(Calendar.DAY_OF_YEAR, -keepDays) val cutoffStr = dateFormatter.format(calendar.time) @@ -186,14 +227,6 @@ class MeasurementLogService private constructor(context: Context) { } } - private fun getAppVersion(): String { - return try { - val context = logDir.parentFile // filesDir parent hack — not ideal - "1.0" // Fallback; ideally inject from BuildConfig - } catch (e: Exception) { - "unknown" - } - } } /** Data class for channel log entries */ 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 new file mode 100644 index 0000000..a209b51 --- /dev/null +++ b/app/src/main/java/com/example/medilightv2android/ui/views/clinical/ClinicalHomeView.kt @@ -0,0 +1,312 @@ +package com.example.medilightv2android.ui.views.clinical + +import android.widget.Toast +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.material.icons.filled.CheckCircle +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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.example.medilightv2android.AppScreen +import com.example.medilightv2android.AppState +import com.example.medilightv2android.models.* +import com.example.medilightv2android.services.ClinicalSessionStore +import com.example.medilightv2android.ui.theme.* + +/** + * Clinical R&D 측정 세션 화면. + * + * 진행: 세션 시작 → device/posture 선택 → 5 alignment 위치 + BV 측정 → 다음 condition → ... → 세션 종료(zip) + * + * 데이터 라우팅: ClinicalSessionStore.currentSession 이 active일 때, + * AdcCsvLogger / MeasurementLogService / BleDebugLogger가 모두 세션 step 폴더로 자동 저장. + */ +@Composable +fun ClinicalHomeView(appState: AppState) { + val context = LocalContext.current + var sessionTick by remember { mutableIntStateOf(0) } // 세션 상태 변경 시 강제 recompose + val session = ClinicalSessionStore.currentSession + + 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 + } + + Column( + modifier = Modifier + .fillMaxSize() + .background(MlBackground) + .verticalScroll(rememberScrollState()) + .padding(16.dp) + ) { + // Top bar + Row(verticalAlignment = Alignment.CenterVertically) { + IconButton(onClick = { 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) + } + + 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)) + Card( + shape = RoundedCornerShape(12.dp), + colors = CardDefaults.cardColors(containerColor = Color.White) + ) { + 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) + } + } + } + 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) + } + } + } + } + } + } + + 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") } + } + ) + } + + 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 }, + label = { Text("Subject ID (예: S001)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = examiner, + onValueChange = { examiner = it }, + label = { Text("Examiner") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + label = { Text("Notes (선택)") }, + modifier = Modifier.fillMaxWidth().height(100.dp) + ) + Spacer(modifier = Modifier.height(24.dp)) + Row(modifier = Modifier.fillMaxWidth()) { + OutlinedButton( + onClick = onCancel, + modifier = Modifier.weight(1f) + ) { Text("Cancel") } + 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) } + } + } +} diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt index 2e06056..23e14a0 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/home/HomeView.kt @@ -87,7 +87,7 @@ fun HomeView(appState: AppState) { modifier = Modifier .fillMaxWidth() .padding(horizontal = 32.dp) - .padding(bottom = 48.dp) + .padding(bottom = 12.dp) .height(60.dp), shape = RoundedCornerShape(18.dp), colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent), @@ -105,5 +105,23 @@ fun HomeView(appState: AppState) { Text("Start", fontSize = 20.sp, fontWeight = FontWeight.Bold, color = Color.White) } } + + // Dev 모드 한정 — Clinical R&D Mode 진입 + if (appState.isDevMode) { + Button( + onClick = { appState.currentScreen = com.example.medilightv2android.AppScreen.CLINICAL_HOME }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 32.dp) + .padding(bottom = 48.dp) + .height(48.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors(containerColor = Color(0xFF7C3AED)) + ) { + Text("Clinical Mode (Dev)", color = Color.White, fontWeight = FontWeight.SemiBold) + } + } else { + Spacer(modifier = Modifier.height(48.dp)) + } } } diff --git a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt index 0fbf727..c109d36 100644 --- a/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt +++ b/app/src/main/java/com/example/medilightv2android/ui/views/monitoring/PlacementGuideView.kt @@ -755,7 +755,12 @@ fun PlacementGuideView(appState: AppState) { logService.logPlacement( channelDetected = detected, - direction = directionHint + direction = directionHint, + urineLens = urineLens.toList(), + cv = lastCv, + lrDev = Double.NaN, // computePlacementGuide 결과의 lrDev는 guideResult에 있음 + phase = placementPhase.name, + score = placementScore ) } } // handler.post