CLINICAL: 흐름 재설계 — 한 측정 = 한 라벨 = 한 폴더

이전: 한 세션이 9 conditions × 6 steps를 모두 묶고 마지막에 zip
새로: 한 측정마다 라벨 입력 → 측정 → 자동 finalize → 다음 측정 라벨

사용자 요청: "session 들어가면 일단 BLE 연결을 하고, 연결 이후에 내가 어떤 자세로
어느 위치 측정할건지를 직접 적고 테스트를 완료한 뒤에 로그가 저장되도록"

변경:
- ClinicalSession 모델: 단일 측정 단위로 재정의 (subjectId/device/posture/step
  /examiner/notes/startedAt/endedAt). folderName에 라벨 전체 + timestamp 포함:
  "S001_A_SUPINE_ALIGN_0CM_2026-05-28_14-30-15"
- ClinicalSessionStore: 단순화. zip 제거. startMeasurement/endMeasurement.
  inClinicalFlow boolean 추가 (DeviceScan/Back 라우팅용).
  lastSubjectId/Examiner/Notes 기억 (다음 측정 시 자동 채움).
  lastFinalizedFolder (UI에 "Saved: ..." 표시용).
- 3 로거: stepLogDir() → currentLogDir() 단순화. 한 폴더에 모든 파일.
- ClinicalHomeView 전면 재작성:
  * BLE 연결 상태 카드 (미연결 시 Connect 버튼 → DeviceScan)
  * 직전 측정 결과 표시 (lastFinalizedFolder)
  * 라벨 입력 폼 (Subject + Device/Posture/Step segmented control + Examiner + Notes)
  * 라벨 preview 실시간
  * Start Measurement 버튼 (BLE 연결 + Subject ID 있을 때만 활성)
- AppState.deviceConnected: 임상 흐름이면 ClinicalHome 복귀 (Personalization 우회)
- MainActivity Back handler:
  * DEVICE_SCAN Back → inClinicalFlow면 ClinicalHome
  * PLACEMENT_GUIDE/PIEZO_MONITORING Back → 활성 세션이면 endMeasurement +
    ClinicalHome 복귀 (폴더 finalize)
  * CLINICAL_HOME Back → inClinicalFlow 해제 + HOME

데이터 구조 (한 측정당):
  Downloads/VesiScan_Sessions/{subjectId}_{device}_{posture}_{step}_{timestamp}/
    ├─ meta.json   (라벨 + 앱/펌웨어/디바이스 버전 + 시작/종료 시각)
    ├─ adc.csv
    ├─ placements.jsonl (alignment) or events.jsonl (BV)
    └─ ble.log

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:26:49 +09:00
parent 6e2dba749b
commit ab25b3c65d
8 changed files with 304 additions and 380 deletions
@@ -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 {
@@ -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
@@ -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<String> = 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}"
}
@@ -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!!
@@ -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!!
}
@@ -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()
}
}
}
}
@@ -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")
}
@@ -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 <T> LabeledSegmented(
label: String,
options: List<T>,
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
)
}
}
}
}
}