diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index f744407..e5295fd 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -39,6 +39,7 @@ 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.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppState import com.medithings.vesiscan.ble.BleManager @@ -85,6 +86,19 @@ fun AnchorAlignView(appState: AppState) { var records by remember { mutableStateOf>(emptyList()) } // 위치를 다 썼는데 종료 조건이 안 걸리면 호출부가 강제 확정한다(AnchorGuide.finish). var forceFinish by remember { mutableStateOf(false) } + /** + * 방금 받은 6채널 원본. 아래쪽 파형이 이걸 그린다. + * + * 정렬은 **어디에 붙일지**를 정하는 단계라, 신호가 제대로 잡히는지 그 자리에서 + * 보이는 것이 본 측정보다 오히려 더 중요하다 — 여기서 잘못 고르면 이후 20회 × + * 조합 전체가 엉뚱한 위치에서 측정된다. + * + * 위치를 옮겨 다시 재면 새 파형으로 덮인다. 측정이 끝나도 비우지 않아, 판정 결과와 + * 마지막 파형을 나란히 보고 다음 위치로 갈지 정할 수 있다. + */ + var liveChannels by remember { + mutableStateOf>(emptyList()) + } // 한 정렬 세션의 시작 시각 — 저장 폴더가 위치마다 갈리지 않게 고정한다. val runStartedAt = remember { Date() } // 저장 폴더 이름. 환자명이 비면 시각으로 대체한다(데이터를 잃지 않기 위해). @@ -110,6 +124,11 @@ fun AnchorAlignView(appState: AppState) { val inbox = kotlinx.coroutines.channels.Channel< List>( kotlinx.coroutines.channels.Channel.CONFLATED) + // 정렬도 자기 조건(2.3MHz·c3)을 프로브에 써 넣는다. 되돌리지 않으면 그 값이 + // 남아, 뒤이어 도는 병원 측정이 "측정 전 값"으로 정렬 조건을 붙잡게 된다. + val guard = ProbeConfigGuard(bleManager) + guard.capture() + val prev = bleManager.piezoCollector.onMultiChannelComplete bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } try { @@ -150,7 +169,9 @@ fun AnchorAlignView(appState: AppState) { error = "측정 시간 초과 (${i}/${AnchorConfig.ALIGN_CYCLES}) — 다시 측정하세요." return@LaunchedEffect } - cycles.add(got.sortedBy { it.channel }.map { ch -> + val sorted = got.sortedBy { it.channel } + liveChannels = sorted // 화면 파형 — 저장·판정에 쓰는 것과 같은 데이터 + cycles.add(sorted.map { ch -> DoubleArray(ch.buffer.size) { k -> ch.buffer[k].toDouble() } }) progress = i @@ -185,6 +206,9 @@ fun AnchorAlignView(appState: AppState) { } } finally { bleManager.piezoCollector.onMultiChannelComplete = prev + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { + guard.restore() + } inbox.close() measuring = false } @@ -323,6 +347,22 @@ fun AnchorAlignView(appState: AppState) { Spacer(Modifier.height(12.dp)) } + // ── 채널 파형 ─────────────────────────────────────────────────── + // 측정 중에도, 끝난 뒤에도 보여준다. 정렬은 위치를 옮겨 가며 반복하는 + // 작업이라 "방금 이 위치에서 뭐가 잡혔는지"를 보고 다음을 정한다. + if (liveChannels.isNotEmpty()) { + Text("채널 파형", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + color = MlSecondaryText) + Spacer(Modifier.height(6.dp)) + SixChannelWaveformGrid( + channels = liveChannels, + modifier = Modifier.fillMaxWidth(), + cellHeight = 140.dp, + compact = true, + ) + Spacer(Modifier.height(16.dp)) + } + if (!done) { Button( onClick = { measuring = true }, diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt index 3d68171..bafefdd 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/HospitalModeView.kt @@ -37,6 +37,7 @@ 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.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppState import com.medithings.vesiscan.ble.BleManager @@ -79,6 +80,14 @@ fun HospitalModeView(appState: AppState) { var posture by remember { mutableStateOf(ClinicalPosture.SUPINE) } var fill by remember { mutableStateOf(BladderFill.P0) } var repeatsText by remember { mutableStateOf(HOSPITAL_DEFAULT_REPEATS.toString()) } + /** + * 이번 실행에서 돌 조합. **기본은 6개 전부** — 프로토콜이 요구하는 것이 전부이고, + * 부분 선택은 예외적인 상황(한 조합만 다시 재기, 시간이 없어 나눠 재기)을 위한 것이다. + * + * 나눠 재도 조건이 완료로 잡힌다 — `HospitalRunStore.scanProgress` 가 같은 + * 자세·충만도의 여러 실행을 **조합 단위로 합산**하기 때문이다. + */ + var selectedCombos by remember { mutableStateOf(HOSPITAL_COMBINATIONS.toSet()) } var running by remember { mutableStateOf(false) } var comboIdx by remember { mutableIntStateOf(0) } @@ -87,6 +96,20 @@ fun HospitalModeView(appState: AppState) { var failCount by remember { mutableIntStateOf(0) } var lastFile by remember { mutableStateOf(null) } var configFailed by remember { mutableStateOf(null) } + /** 프로브 설정 복원 결과. 측정이 끝난 뒤 화면에 한 줄로 남긴다. */ + var restoreNote by remember { mutableStateOf(null) } + /** + * 방금 받은 6채널 원본. 아래쪽 파형 그래프가 이걸 그린다. + * + * 저장하는 값(`raw`)과 **같은 데이터**다 — 화면용으로 따로 재지 않는다. 그래야 + * 눈으로 본 파형과 파일에 들어간 파형이 어긋나지 않는다. + * + * 실행이 끝나도 비우지 않는다. 마지막 회차를 그대로 두면 "방금 뭘 쟀는지"를 다시 + * 눌러 보지 않고도 확인할 수 있다. + */ + var liveChannels by remember { + mutableStateOf>(emptyList()) + } val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS val canStart = isConnected && patient.isNotBlank() && !running @@ -119,7 +142,12 @@ fun HospitalModeView(appState: AppState) { val dir = HospitalRunStore.runDir(patient, startedAt) val fw = bleManager.firmwareVersion.value val results = JSONArray() - okCount = 0; failCount = 0; configFailed = null + okCount = 0; failCount = 0; configFailed = null; restoreNote = null + + // 측정 전 프로브 설정을 읽어 둔다. 이 루프는 조합마다 파라미터를 덮어쓰므로 + // 끝나고 되돌리지 않으면 일반 측정이 임상 조건으로 돌게 된다. + val guard = ProbeConfigGuard(bleManager) + guard.capture() // 6채널 완료를 코루틴에서 받는 통로. 콜백은 BLE 스레드에서 오므로 평범한 var 로 // 주고받으면 가시성이 보장되지 않는다 — Channel 로 넘긴다. @@ -132,7 +160,10 @@ fun HospitalModeView(appState: AppState) { bleManager.piezoCollector.onMultiChannelComplete = { ch -> inbox.trySend(ch) } try { - HOSPITAL_COMBINATIONS.forEachIndexed { ci, (freq, cycle) -> + // 선택 순서가 아니라 **프로토콜 순서**를 지킨다. 매번 같은 순서로 돌아야 + // 나중에 파일을 비교할 때 조건이 섞이지 않는다. + val combos = HOSPITAL_COMBINATIONS.filter { it in selectedCombos } + combos.forEachIndexed { ci, (freq, cycle) -> if (!running) return@forEachIndexed comboIdx = ci val file = HospitalRunStore.combinationFile( @@ -185,7 +216,9 @@ fun HospitalModeView(appState: AppState) { if (got == null) { fail++; failCount++ } else { - val raw = got.sortedBy { it.channel }.map { it.buffer } + val sorted = got.sortedBy { it.channel } + liveChannels = sorted // 화면 파형 — 저장값과 같은 데이터 + val raw = sorted.map { it.buffer } val saved = HospitalRunStore.appendMeasurement( file = file, scanId = AdcCsvLogger.nextScanId(), @@ -212,6 +245,13 @@ fun HospitalModeView(appState: AppState) { } finally { bleManager.piezoCollector.onMultiChannelComplete = prevOnComplete inbox.close() + // 중지·오류로 빠져나온 경우에도 되돌린다 — 오히려 그때가 더 필요하다. + // withContext(NonCancellable) 로 감싸는 이유: `running=false` 로 코루틴이 + // 취소되면 여기의 suspend 호출(응답 대기)이 즉시 끊겨 복원이 안 나간다. + kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { + guard.restore() + } + restoreNote = guard.summary() HospitalRunStore.writeSummary(dir, JSONObject().apply { put("patient", patient) put("posture", posture.label) @@ -335,15 +375,64 @@ fun HospitalModeView(appState: AppState) { label = { Text("조합당 반복 횟수") }, singleLine = true, enabled = !running, - supportingText = { Text("6조합 × ${repeats}회 = 총 ${6 * repeats}회 측정") }, + supportingText = { + Text("${selectedCombos.size}조합 × ${repeats}회 = 총 ${selectedCombos.size * repeats}회 측정") + }, modifier = Modifier.fillMaxWidth(), ) + Spacer(Modifier.height(12.dp)) + + // ── 돌릴 조합 고르기 ──────────────────────────────────────────── + // 기본은 6개 전부다. 프로토콜이 요구하는 것이 전부이고, 여기서 빼는 것은 + // "한 조합만 다시 잰다" 같은 예외를 위한 것이다. 그래서 해제는 되지만 + // 마지막 하나는 못 끄게 한다 — 0개로 시작을 누르면 아무 일도 안 일어난다. + Label("측정할 조합") + CombinationPicker( + selected = selectedCombos, + onToggle = { combo -> + if (!running) { + selectedCombos = + if (combo in selectedCombos) { + if (selectedCombos.size <= 1) selectedCombos + else selectedCombos - combo + } else selectedCombos + combo + } + }, + enabled = !running, + ) + if (selectedCombos.size < HOSPITAL_COMBINATIONS.size) { + Hint( + "프로토콜 6조합 중 ${selectedCombos.size}개만 돕니다. 나머지를 나중에 재도 " + + "같은 자세·충만도면 합산되어 완료로 잡힙니다." + ) + } Spacer(Modifier.height(16.dp)) + // 복원 결과는 측정이 끝난 뒤에만 뜬다. 실패했으면 다음 일반 측정이 임상 + // 조건으로 돌게 되므로 눈에 띄어야 한다. + restoreNote?.let { note -> + val failed = guardRestoreFailed(note) + Text( + note, + fontSize = 12.sp, + color = if (failed) MlCritical else MlSecondaryText, + modifier = Modifier + .fillMaxWidth() + .background( + (if (failed) MlCritical else MlSuccess).copy(alpha = 0.10f), + RoundedCornerShape(8.dp), + ) + .padding(10.dp), + ) + Spacer(Modifier.height(12.dp)) + } + if (running) { - val (f, c) = HOSPITAL_COMBINATIONS[comboIdx] + val runCombos = HOSPITAL_COMBINATIONS.filter { it in selectedCombos } + val (f, c) = runCombos.getOrElse(comboIdx) { runCombos.firstOrNull() ?: HOSPITAL_COMBINATIONS[0] } ProgressPanel( - comboIdx = comboIdx, repeatIdx = repeatIdx, repeats = repeats, + comboIdx = comboIdx, comboCount = runCombos.size, + repeatIdx = repeatIdx, repeats = repeats, freqLabel = f.label, cycles = c.cycles, ok = okCount, fail = failCount, lastFile = lastFile, ) @@ -354,6 +443,27 @@ fun HospitalModeView(appState: AppState) { shape = RoundedCornerShape(14.dp), colors = ButtonDefaults.buttonColors(containerColor = Color(0xFFD32F2F)), ) { Text("중지", color = Color.White, fontWeight = FontWeight.Bold) } + + // ── 채널 파형 ─────────────────────────────────────────────── + // 측정하는 사람이 값이 제대로 잡히는지 **그 자리에서** 보게 한다. + // 20회를 다 돌린 뒤에야 파일을 열어 보면, 잘못 붙은 것을 알았을 때는 + // 이미 그 단계를 다시 잴 수 없다(방광을 비웠거나 자세가 바뀌었다). + // + // 셀을 작게 잡는다(140dp). 이 화면의 주인공은 진행 상황과 중지 버튼이고 + // 파형은 곁눈질로 보는 것이라, 크게 잡으면 중지 버튼이 화면 밖으로 밀린다. + Spacer(Modifier.height(12.dp)) + Label("채널 파형") + Spacer(Modifier.height(6.dp)) + SixChannelWaveformGrid( + channels = liveChannels, + modifier = Modifier.fillMaxWidth(), + cellHeight = 140.dp, + compact = true, + ) + if (liveChannels.isEmpty()) { + Hint("첫 회차를 받으면 파형이 나타납니다.") + } + Spacer(Modifier.height(16.dp)) } else { Button( onClick = { comboIdx = 0; repeatIdx = 0; running = true }, @@ -390,11 +500,16 @@ fun HospitalModeView(appState: AppState) { ) // 주파수 코드값이 확인되기 전까지는 화면에도 남긴다 — 조작자가 데이터의 // 한계를 알고 있어야 한다. + // 남는 값은 **선택한 것 중 프로토콜 순서상 마지막** 조합이다. 6개를 다 돌면 + // 2.3MHz·c7 이지만 부분 선택이면 달라지므로, 박아 두지 않고 실제 값을 보여준다. + // 종전에는 "(2.3MHz · cycle 7)" 로 고정돼 있어 부분 선택 시 틀린 안내였다. + val lastCombo = HOSPITAL_COMBINATIONS.lastOrNull { it in selectedCombos } Text( "⚠ 측정 파라미터(mcs)는 프로브에 영구 저장됩니다 — " + "avg ${HospitalFixedParams.AVG} · delay ${HospitalFixedParams.DELAY_US}µs · " + - "samples ${HospitalFixedParams.SAMPLES}. 측정이 끝나도 마지막 조합" + - "(2.3MHz · cycle 7) 설정이 프로브에 남아 일반 측정에도 적용됩니다.", + "samples ${HospitalFixedParams.SAMPLES}. 측정이 끝나면 마지막으로 돈 조합" + + (lastCombo?.let { "(${it.first.label}MHz · cycle ${it.second.cycles})" } ?: "") + + " 설정이 프로브에 남아 일반 측정에도 적용됩니다.", fontSize = 11.sp, color = Color(0xFFB26A00), modifier = Modifier.padding(top = 6.dp, bottom = 24.dp), ) @@ -402,6 +517,64 @@ fun HospitalModeView(appState: AppState) { } } +/** + * 6조합 다중 선택. 2열 × 3행으로 주파수별 묶어 보여준다. + * + * 칩 한 줄(`ChipRow`)과 달리 **다중 선택**이라 따로 둔다. 진행 칩과 생김새를 맞추되, + * 선택은 "이번에 돌 것"이고 진행 칩의 초록은 "이미 잰 것"이라 의미가 다르므로 색을 + * 겹치지 않게 파랑 계열만 쓴다. + */ +@Composable +private fun CombinationPicker( + selected: Set>, + onToggle: (Pair) -> Unit, + enabled: Boolean, +) { + Column( + Modifier.fillMaxWidth().padding(top = 6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + ProbeFrequency.entries.forEach { f -> + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + ProbeCycle.entries.forEach { c -> + val combo = f to c + val on = combo in selected + Box( + Modifier + .weight(1f) + .background( + if (on) MlPrimary else Color.White, + RoundedCornerShape(10.dp), + ) + .then( + if (!on) Modifier.border(1.dp, MlSecondaryText.copy(alpha = 0.35f), + RoundedCornerShape(10.dp)) + else Modifier + ) + .clickable(enabled = enabled) { onToggle(combo) } + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + "${f.label} · c${c.cycles}", + fontSize = 13.sp, + fontWeight = if (on) FontWeight.Bold else FontWeight.Normal, + color = if (on) Color.White else MlSecondaryText, + ) + } + } + } + } + } +} + +/** 복원 안내가 실패를 말하는가 — 색을 가르는 데만 쓴다. */ +private fun guardRestoreFailed(note: String): Boolean = + note.contains("못했습니다") || note.contains("실패") + @Composable private fun Label(text: String) = Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = MlSecondaryText) @@ -496,17 +669,17 @@ private fun ChipRow( @Composable private fun ProgressPanel( - comboIdx: Int, repeatIdx: Int, repeats: Int, + comboIdx: Int, comboCount: Int, repeatIdx: Int, repeats: Int, freqLabel: String, cycles: Int, ok: Int, fail: Int, lastFile: String?, ) { - val total = HOSPITAL_COMBINATIONS.size * repeats + val total = comboCount * repeats val done = comboIdx * repeats + repeatIdx Column( Modifier.fillMaxWidth() .background(Color.White, RoundedCornerShape(12.dp)) .padding(14.dp) ) { - Text("측정 중 — ${comboIdx + 1}/${HOSPITAL_COMBINATIONS.size} 조합", + Text("측정 중 — ${comboIdx + 1}/$comboCount 조합", fontSize = 15.sp, fontWeight = FontWeight.Bold) Spacer(Modifier.height(4.dp)) Text("$freqLabel MHz · cycle $cycles · $repeatIdx/$repeats 회", @@ -587,14 +760,37 @@ private fun ProbeParamCard(bleManager: BleManager, enabled: Boolean) { .background(Color.White, RoundedCornerShape(12.dp)) .padding(12.dp) ) { - Text("프로브 파라미터 직접 설정", fontSize = 15.sp, fontWeight = FontWeight.Bold) + Text("프로브 파라미터 확인 · 설정", fontSize = 15.sp, fontWeight = FontWeight.Bold) Text( - "고른 값을 프로브에 써 넣습니다. avg ${HospitalFixedParams.AVG} · " + + "프로브에 저장된 값을 읽거나, 고른 값을 써 넣습니다. avg ${HospitalFixedParams.AVG} · " + "delay ${HospitalFixedParams.DELAY_US}µs · samples ${HospitalFixedParams.SAMPLES} 는 고정입니다.", fontSize = 11.sp, color = MlSecondaryText, modifier = Modifier.padding(top = 2.dp, bottom = 8.dp), ) + // ── 현재 프로브 설정 (mcf?) ───────────────────────────────────────── + // 임상 루프가 조합마다 파라미터를 덮어쓰고 되돌리지 않으므로, 일반 측정으로 + // 돌아가기 전에 지금 무엇이 들어 있는지 볼 수 있어야 한다. `mcf?` 는 **읽기만** + // 한다 — 확인하려고 값을 바꾸는 일이 없다. + val readCfg by bleManager.piezoConfigRead + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + readCfg?.let { c -> + c.error?.let { "현재 설정: 읽기 실패 ($it)" } + ?: "현재 설정: freq ${c.freq} · cycle ${c.cycles} · avg ${c.avg} · " + + "delay ${c.delayUs}µs · samples ${c.samples}" + } ?: "현재 설정: 아직 안 읽음", + fontSize = 12.sp, + color = if (readCfg?.error != null) MlCritical else MlSecondaryText, + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { bleManager.sendPiezoConfigQuery() }, + enabled = connected && !busy, + ) { Text("읽기") } + } + Spacer(Modifier.height(8.dp)) + Label("주파수") ChipRow(ProbeFrequency.entries, freq, { freq = it }, enabled && !busy) { "${it.label}MHz"