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,
|
||||
VIVAMYO_MONITORING, PIEZO_MONITORING, MEASUREMENT_HISTORY,
|
||||
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) {
|
||||
|
||||
@@ -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.sensorselect.SensorSelectView
|
||||
import com.example.medilightv2android.ui.views.clinical.ClinicalHomeView
|
||||
import com.example.medilightv2android.ui.views.clinical.ClinicalLiveView
|
||||
|
||||
class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -117,6 +118,11 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
||||
com.example.medilightv2android.services.ClinicalSessionStore.inClinicalFlow = false
|
||||
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.VOIDING_DIARY -> appState.currentScreen = AppScreen.PIEZO_MONITORING
|
||||
@@ -164,6 +170,7 @@ fun MedilightApp(modifier: Modifier = Modifier) {
|
||||
onBack = { appState.backToMonitoring() }
|
||||
)
|
||||
AppScreen.CLINICAL_HOME -> ClinicalHomeView(appState)
|
||||
AppScreen.CLINICAL_LIVE -> ClinicalLiveView(appState)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -44,14 +44,22 @@ data class ClinicalSession(
|
||||
val step: ClinicalStep,
|
||||
val examiner: 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(),
|
||||
var endedAt: Long? = null
|
||||
) {
|
||||
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
|
||||
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 컬럼/필드 값 (라벨링 자동) */
|
||||
val label: String
|
||||
|
||||
@@ -34,14 +34,20 @@ object ClinicalSessionStore {
|
||||
var lastFinalizedFolder: File? = null
|
||||
private set
|
||||
|
||||
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 (examiner/notes) */
|
||||
/** 다음 측정 시 기본값으로 자동 채울 사용자 입력 — 같은 피험자/검사자 여러 측정 시 편의 */
|
||||
var lastExaminer: String = ""
|
||||
var lastNotes: String = ""
|
||||
var lastSubjectId: String = ""
|
||||
var lastTrueVolumeMl: String = "" // 문자열로 보관 (입력 폼과 동일 형식)
|
||||
var lastAbdomenThicknessMm: String = ""
|
||||
|
||||
fun startMeasurement(context: Context, session: ClinicalSession) {
|
||||
currentSession = session
|
||||
lastExaminer = session.examiner
|
||||
lastNotes = session.notes
|
||||
lastSubjectId = session.subjectId
|
||||
lastTrueVolumeMl = session.trueVolumeMl?.toString() ?: ""
|
||||
lastAbdomenThicknessMm = session.abdomenThicknessMm?.toString() ?: ""
|
||||
currentLogDir()?.mkdirs()
|
||||
writeMeta(context)
|
||||
}
|
||||
@@ -68,6 +74,10 @@ object ClinicalSessionStore {
|
||||
put("examiner", s.examiner)
|
||||
put("notes", s.notes)
|
||||
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_iso",
|
||||
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 examiner by remember { mutableStateOf(ClinicalSessionStore.lastExaminer) }
|
||||
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
|
||||
|
||||
@@ -165,6 +169,39 @@ fun ClinicalHomeView(appState: AppState) {
|
||||
)
|
||||
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(
|
||||
value = examiner,
|
||||
onValueChange = { examiner = it },
|
||||
@@ -192,7 +229,7 @@ fun ClinicalHomeView(appState: AppState) {
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Start Measurement
|
||||
// Start Measurement — Live 6ch 시각화 화면으로 진입 (vesiscan_test run_stream 패턴)
|
||||
Button(
|
||||
onClick = {
|
||||
ClinicalSessionStore.startMeasurement(
|
||||
@@ -202,11 +239,13 @@ fun ClinicalHomeView(appState: AppState) {
|
||||
posture = posture,
|
||||
step = step,
|
||||
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
|
||||
else AppScreen.PIEZO_MONITORING
|
||||
appState.currentScreen = AppScreen.CLINICAL_LIVE
|
||||
},
|
||||
enabled = isConnected,
|
||||
modifier = Modifier.fillMaxWidth().height(56.dp),
|
||||
@@ -215,7 +254,7 @@ fun ClinicalHomeView(appState: AppState) {
|
||||
) {
|
||||
Icon(Icons.Default.PlayArrow, null, tint = Color.White)
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
+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