CLINICAL: Dev 모드 한정 임상 R&D 측정 세션 메뉴 추가

회사 내 알고리즘 개발용 임상 시험(3 devices × 3 postures × 6 steps = 54 measurements
per subject)을 위한 데이터 라벨링/라우팅/zip 자동화. Dev 모드 켠 경우에만 노출.

신규 파일:
- models/ClinicalSession.kt — ClinicalDevice/Posture/Step 모델 + 완료 추적
- services/ClinicalSessionStore.kt — 전역 세션 store + 폴더 라우팅 + meta.json + zip
- ui/views/clinical/ClinicalHomeView.kt — 세션 메타 입력 다이얼로그 + 9-conditions
  그리드 + 6 steps + Mark Complete + End Session(zip) 화면

로거 라우팅 (세션 존재 시 자동 분기):
- AdcCsvLogger: sessionRoot/{device}_{posture}/{step}/adc.csv +
  CSV에 clinical_label 컬럼 추가 (S001_A_SUPINE_ALIGN_0CM 형태)
- MeasurementLogService: 내부 filesDir → Downloads/VesiScan_Events/ 영구 이동.
  세션 시 step 폴더의 events.jsonl/placements.jsonl로 분기.
  BuildConfig 대신 PackageInfo로 versionName/versionCode 정확 주입.
  보관 90일 → 365일 (R&D 장기 보관)
- BleDebugLogger: step 변경 감지해서 ble.log 파일 자동 전환

UI/라우팅:
- AppScreen.CLINICAL_HOME + MainActivity Crossfade/BackHandler 분기
- HomeView에 dev 모드 한정 보라색 "Clinical Mode (Dev)" 버튼
- 측정 화면(PlacementGuide/PiezoMonitoring) Back 시 임상 세션 활성 → ClinicalHome 복귀

Placement 구조화:
- PlacementGuideView가 logService.logPlacement() 호출 시 urineLens/cv/phase/score
  추가 인자 전달 → placements.jsonl에 구조화 기록

데이터 구조 (한 세션당):
  Downloads/VesiScan_Sessions/{S001_2026-05-28_14:30}/
    ├─ meta.json
    ├─ A_SUPINE/align_0cm/{adc.csv, placements.jsonl, ble.log}
    ├─ A_SUPINE/align_1cm/...
    ├─ ...
    ├─ A_SUPINE/bv/{adc.csv, events.jsonl, ble.log}
    ├─ A_SITTING/... (총 9 conditions × 6 steps)
    └─ session.zip (End Session 시 자동 생성)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-26 12:06:39 +09:00
parent 63bb8586ec
commit 6e2dba749b
10 changed files with 655 additions and 27 deletions
@@ -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) {
@@ -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)
}
}
}
@@ -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<String> = 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
}
@@ -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()}")
}
@@ -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!!
}
@@ -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()
}
}
}
}
@@ -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<Boolean>,
direction: String
direction: String,
urineLens: List<Int> = 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 */
@@ -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) }
}
}
}
@@ -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))
}
}
}
@@ -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