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,
|
ONBOARDING, CONSENT, REGISTRATION, HOME, SENSOR_SELECT, DEVICE_SCAN,
|
||||||
NIRS_CALIBRATION, PIEZO_PERSONALIZATION, PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
|
NIRS_CALIBRATION, PIEZO_PERSONALIZATION, PLACEMENT_GUIDE, PIN_SETUP, PIN_ENTRY,
|
||||||
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
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) {
|
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.consent.ConsentView
|
||||||
import com.example.medilightv2android.ui.views.reminder.ReminderSettingsView
|
import com.example.medilightv2android.ui.views.reminder.ReminderSettingsView
|
||||||
import com.example.medilightv2android.ui.views.sensorselect.SensorSelectView
|
import com.example.medilightv2android.ui.views.sensorselect.SensorSelectView
|
||||||
|
import com.example.medilightv2android.ui.views.clinical.ClinicalHomeView
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -89,15 +90,23 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 다른 화면에서 뒤로가기 = 바로 전 화면
|
// 다른 화면에서 뒤로가기 = 바로 전 화면
|
||||||
BackHandler(enabled = appState.currentScreen != AppScreen.HOME
|
BackHandler(enabled = appState.currentScreen != AppScreen.HOME
|
||||||
&& appState.currentScreen != AppScreen.PIN_ENTRY) {
|
&& appState.currentScreen != AppScreen.PIN_ENTRY) {
|
||||||
when (appState.currentScreen) {
|
when (appState.currentScreen) {
|
||||||
// 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring
|
// 메인 플로우: Home → DeviceScan → Personalization → Placement → Monitoring
|
||||||
AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.HOME
|
AppScreen.DEVICE_SCAN -> appState.currentScreen = AppScreen.HOME
|
||||||
AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN
|
AppScreen.PIEZO_PERSONALIZATION -> appState.currentScreen = AppScreen.DEVICE_SCAN
|
||||||
AppScreen.PLACEMENT_GUIDE -> appState.backFromPlacementGuide()
|
// 임상 모드면 측정 후 ClinicalHome으로 복귀
|
||||||
AppScreen.PIEZO_MONITORING -> appState.currentScreen = AppScreen.PLACEMENT_GUIDE
|
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.MEASUREMENT_HISTORY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||||
AppScreen.VOIDING_DIARY -> 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(
|
AppScreen.REMINDER_SETTINGS -> ReminderSettingsView(
|
||||||
onBack = { appState.backToMonitoring() }
|
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")
|
sessionFile = File(getDir(), "${sessionFmt.format(Date())}.csv")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 임상 모드: ClinicalSessionStore에 활성 세션이 있으면 그 step 폴더의 adc.csv 사용.
|
||||||
|
* 일반 모드: 기존 세션 파일 사용.
|
||||||
|
*/
|
||||||
private fun getFile(): File {
|
private fun getFile(): File {
|
||||||
|
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
|
||||||
|
if (clinicalStepDir != null) {
|
||||||
|
return File(clinicalStepDir, "adc.csv")
|
||||||
|
}
|
||||||
if (sessionFile == null) newSession()
|
if (sessionFile == null) newSession()
|
||||||
return sessionFile!!
|
return sessionFile!!
|
||||||
}
|
}
|
||||||
@@ -60,10 +68,12 @@ object AdcCsvLogger {
|
|||||||
val dpsStr = "%.3f".format(com.example.medilightv2android.managers.PiezoHW.distancePerSample)
|
val dpsStr = "%.3f".format(com.example.medilightv2android.managers.PiezoHW.distancePerSample)
|
||||||
val sgStr = if (analyzer.useSgFilter) "ON" else "OFF"
|
val sgStr = if (analyzer.useSgFilter) "ON" else "OFF"
|
||||||
|
|
||||||
|
val clinicalLabel = ClinicalSessionStore.currentLabel() // 일반 모드면 ""
|
||||||
|
|
||||||
java.io.FileWriter(file, !isNew).buffered().use { writer ->
|
java.io.FileWriter(file, !isNew).buffered().use { writer ->
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
val header = buildString {
|
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")
|
for (i in 0 until 100) append(",s$i")
|
||||||
}
|
}
|
||||||
writer.write(header)
|
writer.write(header)
|
||||||
@@ -72,7 +82,7 @@ object AdcCsvLogger {
|
|||||||
|
|
||||||
for ((chIdx, buffer) in channels.withIndex()) {
|
for ((chIdx, buffer) in channels.withIndex()) {
|
||||||
val row = buildString {
|
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) {
|
for (sample in buffer) {
|
||||||
append(",${sample.toInt()}")
|
append(",${sample.toInt()}")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,11 +82,24 @@ class BleDebugLogger private constructor(private val context: Context) {
|
|||||||
private val dayFmt = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
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 liveLogFile: File? = null
|
||||||
|
private var liveLogStepLabel: String = "" // 임상 모드 step 변경 감지
|
||||||
private fun ensureLiveLogFile(): File {
|
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)
|
val downloadsDir = android.os.Environment.getExternalStoragePublicDirectory(android.os.Environment.DIRECTORY_DOWNLOADS)
|
||||||
liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log")
|
liveLogFile = File(downloadsDir, "VesiScan_BLE_${dateFmt.format(Date())}.log")
|
||||||
|
liveLogStepLabel = ""
|
||||||
}
|
}
|
||||||
return liveLogFile!!
|
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
|
* File location: vesiscan_logs/YYYY-MM-DD.jsonl
|
||||||
* Each line = one measurement event (JSON)
|
* 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 logDir: File
|
||||||
private val dateFormatter = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
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 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 {
|
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()
|
if (!logDir.exists()) logDir.mkdirs()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +65,10 @@ class MeasurementLogService private constructor(context: Context) {
|
|||||||
put("event", "measurement")
|
put("event", "measurement")
|
||||||
put("timestamp", timestampFormatter.format(Date()))
|
put("timestamp", timestampFormatter.format(Date()))
|
||||||
put("device", Build.MODEL)
|
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) {
|
if (volumeMl != null) {
|
||||||
@@ -86,10 +101,18 @@ class MeasurementLogService private constructor(context: Context) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Log Placement Guide result.
|
* Log Placement Guide result.
|
||||||
|
*
|
||||||
|
* 임상 모드에서는 구조화된 placements.jsonl로 별도 라우팅.
|
||||||
|
* 일반 모드에서는 기존 events JSONL 묶음에 그대로.
|
||||||
*/
|
*/
|
||||||
fun logPlacement(
|
fun logPlacement(
|
||||||
channelDetected: List<Boolean>,
|
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 {
|
val entry = JSONObject().apply {
|
||||||
put("event", "placement")
|
put("event", "placement")
|
||||||
@@ -99,8 +122,18 @@ class MeasurementLogService private constructor(context: Context) {
|
|||||||
put("detected", arr)
|
put("detected", arr)
|
||||||
put("detected_count", channelDetected.count { it })
|
put("detected_count", channelDetected.count { it })
|
||||||
put("direction", direction)
|
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 --
|
// -- 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 {
|
try {
|
||||||
val line = entry.toString() + "\n"
|
val line = entry.toString() + "\n"
|
||||||
val fileName = dateFormatter.format(Date()) + ".jsonl"
|
val clinicalStepDir = ClinicalSessionStore.stepLogDir()
|
||||||
val file = File(logDir, fileName)
|
val file = if (clinicalStepDir != null) {
|
||||||
|
File(clinicalStepDir, clinicalFileName)
|
||||||
|
} else {
|
||||||
|
File(logDir, dateFormatter.format(Date()) + ".jsonl")
|
||||||
|
}
|
||||||
file.appendText(line)
|
file.appendText(line)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Silently fail — logging should never crash the app
|
// Silently fail — logging should never crash the app
|
||||||
@@ -173,8 +214,8 @@ class MeasurementLogService private constructor(context: Context) {
|
|||||||
val totalLogSize: Long
|
val totalLogSize: Long
|
||||||
get() = listLogFiles().sumOf { it.length() }
|
get() = listLogFiles().sumOf { it.length() }
|
||||||
|
|
||||||
/** Purge logs older than [keepDays] days (default 90) */
|
/** Purge logs older than [keepDays] days (default 365, R&D 데이터 장기 보관) */
|
||||||
fun purgeOldLogs(keepDays: Int = 90) {
|
fun purgeOldLogs(keepDays: Int = 365) {
|
||||||
val calendar = Calendar.getInstance()
|
val calendar = Calendar.getInstance()
|
||||||
calendar.add(Calendar.DAY_OF_YEAR, -keepDays)
|
calendar.add(Calendar.DAY_OF_YEAR, -keepDays)
|
||||||
val cutoffStr = dateFormatter.format(calendar.time)
|
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 */
|
/** 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
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 32.dp)
|
.padding(horizontal = 32.dp)
|
||||||
.padding(bottom = 48.dp)
|
.padding(bottom = 12.dp)
|
||||||
.height(60.dp),
|
.height(60.dp),
|
||||||
shape = RoundedCornerShape(18.dp),
|
shape = RoundedCornerShape(18.dp),
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Transparent),
|
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)
|
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(
|
logService.logPlacement(
|
||||||
channelDetected = detected,
|
channelDetected = detected,
|
||||||
direction = directionHint
|
direction = directionHint,
|
||||||
|
urineLens = urineLens.toList(),
|
||||||
|
cv = lastCv,
|
||||||
|
lrDev = Double.NaN, // computePlacementGuide 결과의 lrDev는 guideResult에 있음
|
||||||
|
phase = placementPhase.name,
|
||||||
|
score = placementScore
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} // handler.post
|
} // handler.post
|
||||||
|
|||||||
Reference in New Issue
Block a user