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:
@@ -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?) {
|
||||
@@ -96,8 +97,16 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
||||
// 메인 플로우: 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-18
@@ -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())
|
||||
}
|
||||
appendLog(entry)
|
||||
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, "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 */
|
||||
|
||||
+312
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user