feat: 임상 Live 6채널 시각화 화면 (ClinicalLiveView)
vesiscan_test Section 2 프로토콜 패턴을 안드로이드 앱 안에서 직접 구현.
실제 몸에 센서 붙인 채 6채널 실시간 그래프 보면서 측정 + 저장 가능.
신규 화면 ClinicalLiveView:
- 600ms 간격 mtb? 자동 송신 (Piezo 6ch + IMU 동시)
- 2×3 grid 라이브 차트 (raw ADC waveform, CH0~CH5 색상별)
· y축 0~4095 (12-bit ADC) 고정 + 가로 grid 4분할
· 각 채널 peak 값 표시
- IMU 카드 — ImuAnalysis 기반 자세 + tilt + shake (흔들림 시 빨강 경고)
- 상단: 세션 라벨 + S=Subject TV=TrueVol Ab=Abdomen 한 줄 + #cycle ✓captured
- 컨트롤: Pause/Resume · Capture (수동 저장) · Auto Capture 토글 · End Session
ClinicalSession 모델 확장:
- subjectId / trueVolumeMl / abdomenThicknessMm 필드 추가
- folderName 에 subjectId prefix (예: "S001_VBT26050202_SUPINE_ALIGN_0CM_...")
- meta.json 에 subject_id / true_volume_ml / abdomen_thickness_mm 자동 기록
ClinicalSessionStore:
- lastSubjectId / lastTrueVolumeMl / lastAbdomenThicknessMm 기억 (다음 측정 자동 채움)
ClinicalHomeView 입력 폼 확장:
- Subject ID 텍스트필드
- True Volume (mL) + Abdomen (mm) 2-컬럼 숫자 입력
- Start Live Measurement 버튼 → CLINICAL_LIVE 화면으로 직행
(이전: alignment/BV step에 따라 PlacementGuide/PiezoMonitoring 분기)
AppState/MainActivity:
- AppScreen.CLINICAL_LIVE 추가
- MainActivity Back: LIVE 시 endMeasurement + ClinicalHome 복귀
데이터 흐름:
Start Live → ClinicalLiveView 진입 → mtb? 600ms loop
→ piezoCollector → 6채널 buffer (자동 또는 수동 capture)
→ AdcCsvLogger.log → adc.csv 누적
→ imuCollector → 15 IMU samples
→ ImuCsvLogger.log → imu.csv 누적
End/Back → endMeasurement → meta.json finalize → ClinicalHome 복귀
장점:
- 검사자가 실제 시연 중 6채널 신호 품질 즉시 확인
- Auto Capture로 매 사이클 자동 저장 (Pause로 일시 멈춤)
- subject_id 기반 폴더명으로 피험자별 grouping 자연스럽게
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,7 +15,8 @@ enum class AppScreen {
|
|||||||
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 측정 세션 화면
|
CLINICAL_HOME, // ★ Dev 모드 한정 — 임상 R&D 측정 세션 화면 (입력 폼)
|
||||||
|
CLINICAL_LIVE // ★ Dev 모드 한정 — 6채널 라이브 시각화 + Capture
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppState(private val context: Context) {
|
class AppState(private val context: Context) {
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ 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
|
import com.example.medilightv2android.ui.views.clinical.ClinicalHomeView
|
||||||
|
import com.example.medilightv2android.ui.views.clinical.ClinicalLiveView
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
@@ -117,6 +118,11 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
|||||||
com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow = false
|
com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow = false
|
||||||
appState.currentScreen = AppScreen.HOME
|
appState.currentScreen = AppScreen.HOME
|
||||||
}
|
}
|
||||||
|
AppScreen.CLINICAL_LIVE -> {
|
||||||
|
// 측정 폴더 finalize 후 ClinicalHome 복귀
|
||||||
|
com.example.medilightv2android.services.ClinicalSessionStore.endMeasurement(context)
|
||||||
|
appState.currentScreen = AppScreen.CLINICAL_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
|
||||||
@@ -164,6 +170,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
|||||||
onBack = { appState.backToMonitoring() }
|
onBack = { appState.backToMonitoring() }
|
||||||
)
|
)
|
||||||
AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState)
|
AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState)
|
||||||
|
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(appState)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,14 +44,22 @@ data class ClinicalSession(
|
|||||||
val step: ClinicalStep,
|
val step: ClinicalStep,
|
||||||
val examiner: String = "",
|
val examiner: String = "",
|
||||||
val notes: String = "",
|
val notes: String = "",
|
||||||
|
// 임상 시험 정보 (vesiscan_test Section 2-1 프로토콜과 매칭)
|
||||||
|
val subjectId: String = "", // 피험자 ID (예: "S001")
|
||||||
|
val trueVolumeMl: Double? = null, // 외부 초음파 측정기 참값 (mL)
|
||||||
|
val abdomenThicknessMm: Double? = null, // 복부 두께 측정기 참값 (mm)
|
||||||
val startedAt: Long = System.currentTimeMillis(),
|
val startedAt: Long = System.currentTimeMillis(),
|
||||||
var endedAt: Long? = null
|
var endedAt: Long? = null
|
||||||
) {
|
) {
|
||||||
private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
private val folderFmt = SimpleDateFormat("yyyy-MM-dd_HHmmss", Locale.US)
|
||||||
|
|
||||||
/** "VBT26050202_SUPINE_ALIGN_0CM_2026-05-28_14-30-15" */
|
/** "S001_VBT26050202_SUPINE_ALIGN_0CM_2026-05-28_14-30-15"
|
||||||
|
* subjectId 비어있으면 기존 형식 유지 (deviceName_posture_step_timestamp). */
|
||||||
val folderName: String
|
val folderName: String
|
||||||
get() = "${deviceName}_${posture.name}_${step.name}_${folderFmt.format(Date(startedAt))}"
|
get() = buildString {
|
||||||
|
if (subjectId.isNotBlank()) { append(subjectId); append('_') }
|
||||||
|
append("${deviceName}_${posture.name}_${step.name}_${folderFmt.format(Date(startedAt))}")
|
||||||
|
}
|
||||||
|
|
||||||
/** clinical_label 컬럼/필드 값 (라벨링 자동) */
|
/** clinical_label 컬럼/필드 값 (라벨링 자동) */
|
||||||
val label: String
|
val label: String
|
||||||
|
|||||||
@@ -34,14 +34,20 @@ object ClinicalSessionStore {
|
|||||||
var lastFinalizedFolder: File? = null
|
var lastFinalizedFolder: File? = null
|
||||||
private set
|
private set
|
||||||
|
|
||||||
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 (examiner/notes) */
|
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 — 같은 피험자/검사자 여러 측정 시 편의 */
|
||||||
var lastExaminer: String = ""
|
var lastExaminer: String = ""
|
||||||
var lastNotes: String = ""
|
var lastNotes: String = ""
|
||||||
|
var lastSubjectId: String = ""
|
||||||
|
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
|
||||||
|
var lastAbdomenThicknessMm: String = ""
|
||||||
|
|
||||||
fun startMeasurement(context: Context, session: ClinicalSession) {
|
fun startMeasurement(context: Context, session: ClinicalSession) {
|
||||||
currentSession = session
|
currentSession = session
|
||||||
lastExaminer = session.examiner
|
lastExaminer = session.examiner
|
||||||
lastNotes = session.notes
|
lastNotes = session.notes
|
||||||
|
lastSubjectId = session.subjectId
|
||||||
|
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
|
||||||
|
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
|
||||||
currentLogDir()?.mkdirs()
|
currentLogDir()?.mkdirs()
|
||||||
writeMeta(context)
|
writeMeta(context)
|
||||||
}
|
}
|
||||||
@@ -68,6 +74,10 @@ object ClinicalSessionStore {
|
|||||||
put("examiner", s.examiner)
|
put("examiner", s.examiner)
|
||||||
put("notes", s.notes)
|
put("notes", s.notes)
|
||||||
put("label", s.label)
|
put("label", s.label)
|
||||||
|
// 임상 시험 참값 (외부 측정기, vesiscan_test 프로토콜)
|
||||||
|
if (s.subjectId.isNotBlank()) put("subject_id", s.subjectId)
|
||||||
|
s.trueVolumeMl?.let { put("true_volume_ml", it) }
|
||||||
|
s.abdomenThicknessMm?.let { put("abdomen_thickness_mm", it) }
|
||||||
put("started_at", s.startedAt)
|
put("started_at", s.startedAt)
|
||||||
put("started_at_iso",
|
put("started_at_iso",
|
||||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
|
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(Date(s.startedAt)))
|
||||||
|
|||||||
+44
-5
@@ -59,6 +59,10 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
var step by remember { mutableStateOf(ClinicalStep.ALIGN_0CM) }
|
var step by remember { mutableStateOf(ClinicalStep.ALIGN_0CM) }
|
||||||
var examiner by remember { mutableStateOf(ClinicalSessionStore.lastExaminer) }
|
var examiner by remember { mutableStateOf(ClinicalSessionStore.lastExaminer) }
|
||||||
var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) }
|
var notes by remember { mutableStateOf(ClinicalSessionStore.lastNotes) }
|
||||||
|
// 임상 시험 정보 (vesiscan_test Section 2-1 프로토콜)
|
||||||
|
var subjectId by remember { mutableStateOf(ClinicalSessionStore.lastSubjectId) }
|
||||||
|
var trueVolText by remember { mutableStateOf(ClinicalSessionStore.lastTrueVolumeMl) }
|
||||||
|
var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) }
|
||||||
|
|
||||||
val lastFolder = ClinicalSessionStore.lastFinalizedFolder
|
val lastFolder = ClinicalSessionStore.lastFinalizedFolder
|
||||||
|
|
||||||
@@ -165,6 +169,39 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(12.dp))
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// 임상 시험 참값 — vesiscan_test Section 2-1 프로토콜과 매칭
|
||||||
|
OutlinedTextField(
|
||||||
|
value = subjectId,
|
||||||
|
onValueChange = { subjectId = it },
|
||||||
|
label = { Text("Subject ID (예: S001)") },
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = trueVolText,
|
||||||
|
onValueChange = { trueVolText = it.filter { c -> c.isDigit() || c == '.' } },
|
||||||
|
label = { Text("True Volume (mL)") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||||
|
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = abdomenText,
|
||||||
|
onValueChange = { abdomenText = it.filter { c -> c.isDigit() || c == '.' } },
|
||||||
|
label = { Text("Abdomen (mm)") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(
|
||||||
|
keyboardType = androidx.compose.ui.text.input.KeyboardType.Number
|
||||||
|
),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = examiner,
|
value = examiner,
|
||||||
onValueChange = { examiner = it },
|
onValueChange = { examiner = it },
|
||||||
@@ -192,7 +229,7 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// Start Measurement
|
// Start Measurement — Live 6ch 시각화 화면으로 진입 (vesiscan_test run_stream 패턴)
|
||||||
Button(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
ClinicalSessionStore.startMeasurement(
|
ClinicalSessionStore.startMeasurement(
|
||||||
@@ -202,11 +239,13 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
posture = posture,
|
posture = posture,
|
||||||
step = step,
|
step = step,
|
||||||
examiner = examiner.trim(),
|
examiner = examiner.trim(),
|
||||||
notes = notes.trim()
|
notes = notes.trim(),
|
||||||
|
subjectId = subjectId.trim(),
|
||||||
|
trueVolumeMl = trueVolText.toDoubleOrNull(),
|
||||||
|
abdomenThicknessMm = abdomenText.toDoubleOrNull(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
appState.currentScreen = if (step.isAlignment) AppScreen.PLACEMENT_GUIDE
|
appState.currentScreen = AppScreen.CLINICAL_LIVE
|
||||||
else AppScreen.PIEZO_MONITORING
|
|
||||||
},
|
},
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||||
@@ -215,7 +254,7 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
) {
|
) {
|
||||||
Icon(Icons.Default.PlayArrow, null, tint = Color.White)
|
Icon(Icons.Default.PlayArrow, null, tint = Color.White)
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
Text("Start Measurement", color = Color.White,
|
Text("Start Live Measurement", color = Color.White,
|
||||||
fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
fontWeight = FontWeight.Bold, fontSize = 16.sp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+356
@@ -0,0 +1,356 @@
|
|||||||
|
package com.example.medilightv2android.ui.views.clinical
|
||||||
|
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
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.PlayArrow
|
||||||
|
import androidx.compose.material.icons.filled.Stop
|
||||||
|
import androidx.compose.material.icons.filled.Save
|
||||||
|
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.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.Path
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
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.ble.BleManager
|
||||||
|
import com.example.medilightv2android.ble.ImuSample
|
||||||
|
import com.example.medilightv2android.ble.PiezoChannelData
|
||||||
|
import com.example.medilightv2android.managers.ImuAnalysis
|
||||||
|
import com.example.medilightv2android.services.AdcCsvLogger
|
||||||
|
import com.example.medilightv2android.services.ClinicalSessionStore
|
||||||
|
import com.example.medilightv2android.services.ImuCsvLogger
|
||||||
|
import com.example.medilightv2android.services.MeasurementLogService
|
||||||
|
import com.example.medilightv2android.ui.theme.*
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clinical Live 6채널 시각화 화면.
|
||||||
|
*
|
||||||
|
* vesiscan_test 의 run_stream 패턴을 안드로이드 앱 내에서 직접 구현:
|
||||||
|
* - 600ms 간격으로 mtb? 송신 → 6채널 + IMU 자동 수집
|
||||||
|
* - 2×3 grid 로 채널별 실시간 raw ADC 그래프
|
||||||
|
* - 상단: 세션 정보 (Subject ID / Posture / Step / True Vol / Abdomen)
|
||||||
|
* - 측정 상태 (cycle 번호, 6/6 채널 도착률, IMU 자세/흔들림)
|
||||||
|
* - 하단 컨트롤: Pause/Resume / Capture / End
|
||||||
|
* - Capture: 현재 시점 1 사이클을 Clinical 폴더에 저장
|
||||||
|
* - 자동 Capture 모드: 사이클마다 자동 저장 토글
|
||||||
|
* - End: 세션 finalize → ClinicalHome 복귀
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ClinicalLiveView(appState: AppState) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val bleManager = remember { BleManager.getInstance(context) }
|
||||||
|
val session = ClinicalSessionStore.currentSession
|
||||||
|
|
||||||
|
// 세션 없으면 즉시 복귀 (잘못된 진입)
|
||||||
|
if (session == null) {
|
||||||
|
LaunchedEffect(Unit) { appState.currentScreen = AppScreen.CLINICAL_HOME }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 측정 state
|
||||||
|
var isMeasuring by remember { mutableStateOf(true) } // 시작 시 자동 측정
|
||||||
|
var autoCapture by remember { mutableStateOf(true) } // 매 사이클 자동 저장
|
||||||
|
var cycleCount by remember { mutableIntStateOf(0) }
|
||||||
|
var capturedCount by remember { mutableIntStateOf(0) }
|
||||||
|
var lastChannels by remember { mutableStateOf<List<PiezoChannelData>>(emptyList()) }
|
||||||
|
var lastImu by remember { mutableStateOf<List<ImuSample>>(emptyList()) }
|
||||||
|
var lastCycleMs by remember { mutableLongStateOf(0L) }
|
||||||
|
|
||||||
|
// BLE 콜백 등록 — 측정 사이클 결과 받음
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
bleManager.piezoCollector.onMultiChannelComplete = { channels ->
|
||||||
|
lastChannels = channels
|
||||||
|
cycleCount++
|
||||||
|
lastCycleMs = System.currentTimeMillis()
|
||||||
|
// 자동 저장 모드면 매 사이클 저장
|
||||||
|
if (autoCapture && ClinicalSessionStore.currentSession != null) {
|
||||||
|
val rawADC = channels.sortedBy { it.channel }.map { it.buffer }
|
||||||
|
AdcCsvLogger.log(rawADC, null)
|
||||||
|
capturedCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bleManager.imuCollector.onComplete = { imuSamples ->
|
||||||
|
lastImu = imuSamples
|
||||||
|
if (autoCapture && ClinicalSessionStore.currentSession != null) {
|
||||||
|
ImuCsvLogger.log(imuSamples)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
bleManager.piezoCollector.onMultiChannelComplete = null
|
||||||
|
bleManager.imuCollector.onComplete = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 측정 루프 — 600ms 간격
|
||||||
|
LaunchedEffect(isMeasuring) {
|
||||||
|
if (!isMeasuring) return@LaunchedEffect
|
||||||
|
while (isMeasuring) {
|
||||||
|
bleManager.sendMtb()
|
||||||
|
delay(600)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(MlBackground)
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
) {
|
||||||
|
// ── Top bar ──
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
ClinicalSessionStore.endMeasurement(context)
|
||||||
|
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||||
|
}) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back", tint = MlPrimary)
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text("Live · ${session.label}",
|
||||||
|
fontSize = 14.sp, fontWeight = FontWeight.Bold)
|
||||||
|
val subj = if (session.subjectId.isNotBlank()) "S=${session.subjectId} · " else ""
|
||||||
|
val tv = session.trueVolumeMl?.let { "TV=${it.toInt()}mL · " } ?: ""
|
||||||
|
val ab = session.abdomenThicknessMm?.let { "Ab=${"%.0f".format(it)}mm" } ?: ""
|
||||||
|
Text("$subj$tv$ab",
|
||||||
|
fontSize = 11.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
// Cycle counter
|
||||||
|
Text("#$cycleCount ✓$capturedCount",
|
||||||
|
fontSize = 13.sp, fontWeight = FontWeight.Bold, color = MlPrimary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6ch grid (2 rows × 3 cols) ──
|
||||||
|
SixChannelGrid(
|
||||||
|
channels = lastChannels,
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// ── IMU 정보 ──
|
||||||
|
if (lastImu.isNotEmpty()) {
|
||||||
|
val posture = remember(lastImu) { ImuAnalysis.estimatePosture(lastImu) }
|
||||||
|
val tilt = remember(lastImu) { ImuAnalysis.tiltDegrees(lastImu) }
|
||||||
|
val shake = remember(lastImu) { ImuAnalysis.shakeRmsDps(lastImu) }
|
||||||
|
Card(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||||
|
shape = RoundedCornerShape(8.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = if (shake > 30f) Color(0xFFFFEBEE) else Color(0xFFE8F5E9)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text("IMU", fontSize = 11.sp, color = MlSecondaryText)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("$posture · tilt=${"%.0f".format(tilt)}° · shake=${"%.1f".format(shake)}dps",
|
||||||
|
fontSize = 12.sp, fontWeight = FontWeight.SemiBold,
|
||||||
|
color = if (shake > 30f) Color.Red else Color(0xFF2E7D32))
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
Text("${lastImu.size} samples", fontSize = 10.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// ── 컨트롤 영역 ──
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
|
) {
|
||||||
|
// Pause/Resume
|
||||||
|
Button(
|
||||||
|
onClick = { isMeasuring = !isMeasuring },
|
||||||
|
modifier = Modifier.weight(1f).height(48.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = if (isMeasuring) Color(0xFFFF9800) else Color(0xFF4CAF50)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Icon(if (isMeasuring) Icons.Default.Stop else Icons.Default.PlayArrow,
|
||||||
|
null, tint = Color.White)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text(if (isMeasuring) "Pause" else "Resume", color = Color.White,
|
||||||
|
fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
// Manual Capture (자동 OFF일 때만 의미 있음)
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
if (lastChannels.isNotEmpty()) {
|
||||||
|
val rawADC = lastChannels.sortedBy { it.channel }.map { it.buffer }
|
||||||
|
AdcCsvLogger.log(rawADC, null)
|
||||||
|
if (lastImu.isNotEmpty()) ImuCsvLogger.log(lastImu)
|
||||||
|
capturedCount++
|
||||||
|
Toast.makeText(context, "Captured #${capturedCount}",
|
||||||
|
Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = lastChannels.isNotEmpty(),
|
||||||
|
modifier = Modifier.weight(1f).height(48.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||||
|
) {
|
||||||
|
Icon(Icons.Default.Save, null, tint = Color.White)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("Capture", color = Color.White, fontWeight = FontWeight.SemiBold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// 자동 캡쳐 토글
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Switch(
|
||||||
|
checked = autoCapture,
|
||||||
|
onCheckedChange = { autoCapture = it },
|
||||||
|
colors = SwitchDefaults.colors(checkedTrackColor = MlPrimary)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text("Auto Capture (매 사이클 자동 저장)",
|
||||||
|
fontSize = 12.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
// End Session
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
isMeasuring = false
|
||||||
|
val folder = ClinicalSessionStore.endMeasurement(context)
|
||||||
|
folder?.let {
|
||||||
|
Toast.makeText(context, "Saved ($capturedCount cycles): ${it.name}",
|
||||||
|
Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).height(48.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.85f))
|
||||||
|
) {
|
||||||
|
Text("End Session", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text("Cycle: ~600ms · Captured: $capturedCount · ${ClinicalSessionStore.currentLogDir()?.name ?: ""}",
|
||||||
|
modifier = Modifier.padding(horizontal = 12.dp),
|
||||||
|
fontSize = 10.sp, color = MlSecondaryText)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 2×3 grid 6채널 라이브 차트 */
|
||||||
|
@Composable
|
||||||
|
private fun SixChannelGrid(
|
||||||
|
channels: List<PiezoChannelData>,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val channelColors = listOf(
|
||||||
|
Color(0xFF1976D2), Color(0xFF388E3C), Color(0xFFE64A19),
|
||||||
|
Color(0xFF7B1FA2), Color(0xFF00838F), Color(0xFFD81B60),
|
||||||
|
)
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
for (row in 0 until 2) {
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
for (col in 0 until 3) {
|
||||||
|
val ch = row * 3 + col
|
||||||
|
val data = channels.find { it.channel == ch }
|
||||||
|
Box(modifier = Modifier.weight(1f).padding(2.dp)) {
|
||||||
|
ChannelChart(
|
||||||
|
channelIdx = ch,
|
||||||
|
buffer = data?.buffer ?: emptyList(),
|
||||||
|
color = channelColors[ch],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 한 채널 그래프 (raw ADC waveform) */
|
||||||
|
@Composable
|
||||||
|
private fun ChannelChart(
|
||||||
|
channelIdx: Int,
|
||||||
|
buffer: List<UShort>,
|
||||||
|
color: Color,
|
||||||
|
) {
|
||||||
|
Card(
|
||||||
|
shape = RoundedCornerShape(6.dp),
|
||||||
|
colors = CardDefaults.cardColors(containerColor = Color.White),
|
||||||
|
modifier = Modifier.fillMaxWidth().height(120.dp)
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.fillMaxSize().padding(4.dp)) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(8.dp)
|
||||||
|
.clip(RoundedCornerShape(2.dp))
|
||||||
|
.background(color)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("CH$channelIdx", fontSize = 10.sp, fontWeight = FontWeight.Bold)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
if (buffer.isNotEmpty()) {
|
||||||
|
val peak = buffer.maxOrNull()?.toInt() ?: 0
|
||||||
|
Text("$peak", fontSize = 9.sp, color = MlSecondaryText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(2.dp))
|
||||||
|
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||||
|
if (buffer.isEmpty()) return@Canvas
|
||||||
|
val w = size.width
|
||||||
|
val h = size.height
|
||||||
|
val n = buffer.size
|
||||||
|
if (n < 2) return@Canvas
|
||||||
|
// y 범위: 0~4095 (12-bit ADC), 일부 여백
|
||||||
|
val yMin = 0f
|
||||||
|
val yMax = 4095f
|
||||||
|
val yScale = h / (yMax - yMin)
|
||||||
|
val xScale = w / (n - 1).toFloat()
|
||||||
|
|
||||||
|
// 가로 grid (4분할)
|
||||||
|
for (i in 1..3) {
|
||||||
|
val y = h * i / 4f
|
||||||
|
drawLine(
|
||||||
|
Color.LightGray.copy(alpha = 0.3f),
|
||||||
|
Offset(0f, y), Offset(w, y),
|
||||||
|
strokeWidth = 0.5f
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waveform
|
||||||
|
val path = Path()
|
||||||
|
for (i in buffer.indices) {
|
||||||
|
val x = i * xScale
|
||||||
|
val y = h - (buffer[i].toInt() - yMin) * yScale
|
||||||
|
if (i == 0) path.moveTo(x, y) else path.lineTo(x, y)
|
||||||
|
}
|
||||||
|
drawPath(path, color, style = Stroke(width = 1.5f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user