diff --git a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt index 9ec8985..e4be8e3 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -149,4 +149,60 @@ object HospitalRunStore { .writeText(json.toString(2), Charsets.UTF_8) } } + + // ── 진행 현황 ──────────────────────────────────────────────────────── + // + // 간호사가 자세·방광 단계를 하나씩 올려 가며 재는데, 중간에 자리를 비우거나 + // 순서가 꼬이면 "0% 했던가?"를 기억으로 판단하게 된다. 그러면 한 단계를 통째로 + // 빠뜨리거나(방광을 다시 채워야 한다) 같은 조건을 두 번 재게 된다. + // 화면이 대신 기억하게 한다. + + /** 한 조건(자세 × 방광 단계)의 진행 상태. */ + enum class RunProgress { NONE, PARTIAL, DONE } + + /** + * 판정 근거는 **파일 존재가 아니라 실행 매니페스트**다. + * + * CSV 파일은 첫 회차만 저장돼도 만들어진다. 파일 유무로 판단하면 20회 중 2회에서 + * 끊긴 것도 "완료"로 보이고, 그게 제일 위험하다 — 간호사는 다 됐다고 믿고 방광을 + * 비우는데 데이터는 못 쓴다. 매니페스트에는 조합별 planned/saved 가 남으므로 + * "6조합이 전부 계획한 횟수를 채웠는가"를 정확히 볼 수 있다. + * + * 매니페스트는 `finally` 에서 쓰므로 중단된 실행에도 남는다. 그 경우 조합이 + * 모자라거나 saved < planned 라 PARTIAL 로 잡힌다. + * + * 같은 조건을 여러 번 실행했으면(정상적인 재측정) **가장 좋은 결과**를 취한다. + * 한 번이라도 제대로 받았으면 그 조건은 끝난 것이다. + * + * @return (자세 label, 방광 %) → 진행 상태 + */ + fun scanProgress(patient: String, day: Date): Map, RunProgress> { + val dir = runDirIfExists(patient, day) ?: return emptyMap() + val out = HashMap, RunProgress>() + dir.listFiles { f -> f.isFile && f.name.startsWith("run_") && f.name.endsWith(".json") } + ?.forEach { f -> + runCatching { + val o = JSONObject(f.readText(Charsets.UTF_8)) + val key = o.getString("posture") to o.getInt("fill_pct") + val combos = o.getJSONArray("combinations") + // 6조합이 다 있고, 각 조합이 계획한 횟수를 채웠을 때만 DONE. + var done = combos.length() == EXPECTED_COMBINATIONS + for (i in 0 until combos.length()) { + val c = combos.getJSONObject(i) + if (c.optInt("saved") < c.optInt("planned", Int.MAX_VALUE)) done = false + } + val st = if (done) RunProgress.DONE else RunProgress.PARTIAL + // 재측정 — 더 좋은 쪽으로만 올린다. + if (out[key] != RunProgress.DONE) out[key] = st + } + } + return out + } + + /** 읽기 전용 조회 — [runDir] 과 달리 폴더를 만들지 않는다. */ + private fun runDirIfExists(patient: String, day: Date): File? = + File(File(downloads(), ROOT), "${dayFmt.format(day)}_${safe(patient)}").takeIf { it.isDirectory } + + /** 주파수 2종 × cycle 3종. [HOSPITAL_COMBINATIONS] 와 같은 수여야 한다. */ + private const val EXPECTED_COMBINATIONS = 6 } 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 94175b7..8058d79 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 @@ -44,7 +44,10 @@ import com.medithings.vesiscan.models.* import com.medithings.vesiscan.services.AdcCsvLogger import com.medithings.vesiscan.services.HospitalRunStore import com.medithings.vesiscan.ui.theme.* +import androidx.compose.foundation.border +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import org.json.JSONArray import org.json.JSONObject @@ -86,6 +89,27 @@ fun HospitalModeView(appState: AppState) { val repeats = repeatsText.toIntOrNull()?.coerceIn(1, 200) ?: HOSPITAL_DEFAULT_REPEATS val canStart = isConnected && patient.isNotBlank() && !running + // ── 진행 현황 ──────────────────────────────────────────────────────── + // 오늘·이 환자의 폴더에 남은 실행 매니페스트를 읽어 "어디까지 했는지"를 칩에 입힌다. + // `running` 이 바뀔 때마다(= 실행이 끝날 때마다) 다시 읽어 방금 잰 단계가 즉시 채워진다. + var progress by remember { mutableStateOf(emptyMap, HospitalRunStore.RunProgress>()) } + LaunchedEffect(patient, running) { + progress = if (patient.isBlank()) emptyMap() + // 디스크 접근이라 IO 로 뺀다. 작은 JSON 몇 개지만 UI 스레드에서 파일을 읽지 않는다. + else withContext(Dispatchers.IO) { + runCatching { HospitalRunStore.scanProgress(patient, Date()) }.getOrDefault(emptyMap()) + } + } + // 자세 칩은 그 자세의 6단계가 전부 끝났을 때만 완료로 본다. + fun postureProgress(p: ClinicalPosture): HospitalRunStore.RunProgress { + val states = BladderFill.entries.map { progress[p.label to it.percent] ?: HospitalRunStore.RunProgress.NONE } + return when { + states.all { it == HospitalRunStore.RunProgress.DONE } -> HospitalRunStore.RunProgress.DONE + states.any { it != HospitalRunStore.RunProgress.NONE } -> HospitalRunStore.RunProgress.PARTIAL + else -> HospitalRunStore.RunProgress.NONE + } + } + // ── 측정 루프 ──────────────────────────────────────────────────────── LaunchedEffect(running) { if (!running) return@LaunchedEffect @@ -250,11 +274,19 @@ fun HospitalModeView(appState: AppState) { Spacer(Modifier.height(12.dp)) Label("자세") - ChipRow(ClinicalPosture.entries, posture, { posture = it }, !running) { it.label } + ChipRow(ClinicalPosture.entries, posture, { posture = it }, !running, + progressOf = { postureProgress(it) }) { it.label } Spacer(Modifier.height(12.dp)) Label("방광 채움 정도 — 간호사가 채워 둔 값") - ChipRow(BladderFill.entries, fill, { fill = it }, !running) { it.label } + // 방광 단계는 **지금 고른 자세 안에서**의 진행이다. 자세를 바꾸면 표시도 바뀐다. + ChipRow(BladderFill.entries, fill, { fill = it }, !running, + progressOf = { progress[posture.label to it.percent] ?: HospitalRunStore.RunProgress.NONE } + ) { it.label } + Hint( + if (patient.isBlank()) "환자명을 넣으면 오늘 이 환자의 진행 상황이 표시된다." + else "✓ 완료 · · 일부(다시 눌러 마저 측정)" + ) Spacer(Modifier.height(12.dp)) OutlinedTextField( @@ -345,9 +377,29 @@ private fun StatusRow(connected: Boolean, deviceName: String?) { ) } +/** + * 선택 칩 한 줄. [progressOf] 를 주면 각 칩이 **이미 측정했는지**를 함께 보여준다. + * + * 색을 세 갈래로 나눈다: + * · 선택됨 파랑 — "지금 이걸 잰다" + * · 완료 초록 + 체크 — "이건 끝났다" + * · 일부 주황 테두리 — "시작했는데 다 못 채웠다" + * + * 완료와 일부를 굳이 나눈 이유: 20회 중 몇 회에서 끊긴 것을 완료로 보여주면 + * 간호사가 다 됐다고 믿고 방광을 비운다. 그 데이터는 못 쓰고 그 단계는 그날 + * 다시 못 잰다. "덜 됐다"가 보여야 다시 누를 수 있다. + * + * 선택 상태가 완료 표시를 덮는다 — 지금 무엇을 재려는지가 먼저 보여야 하고, + * 완료 여부는 체크 표시로 계속 남는다. + */ @Composable private fun ChipRow( - items: List, selected: T, onSelect: (T) -> Unit, enabled: Boolean, label: (T) -> String, + items: List, + selected: T, + onSelect: (T) -> Unit, + enabled: Boolean, + progressOf: ((T) -> HospitalRunStore.RunProgress)? = null, + label: (T) -> String, ) { Row( Modifier.fillMaxWidth().padding(top = 6.dp), @@ -355,21 +407,44 @@ private fun ChipRow( ) { items.forEach { item -> val on = item == selected + val st = progressOf?.invoke(item) ?: HospitalRunStore.RunProgress.NONE + val done = st == HospitalRunStore.RunProgress.DONE + val partial = st == HospitalRunStore.RunProgress.PARTIAL + + val bg = when { + on -> MlPrimary + done -> MlSuccess.copy(alpha = 0.18f) + else -> Color.White + } + val fg = when { + on -> Color.White + done -> MlSuccess + partial -> MlWarning + else -> MlSecondaryText + } Box( Modifier .weight(1f) - .background( - if (on) MlPrimary else Color.White, - RoundedCornerShape(10.dp), + .background(bg, RoundedCornerShape(10.dp)) + // 일부만 잰 칩은 테두리로 표시한다. 배경을 채우면 완료와 헷갈린다. + .then( + if (partial && !on) Modifier.border(1.5.dp, MlWarning, RoundedCornerShape(10.dp)) + else Modifier ) .clickable(enabled = enabled) { onSelect(item) } .padding(vertical = 10.dp), contentAlignment = Alignment.Center, ) { Text( - label(item), fontSize = 13.sp, - fontWeight = if (on) FontWeight.Bold else FontWeight.Normal, - color = if (on) Color.White else MlSecondaryText, + // 색을 못 알아보는 경우(색각 이상·화면 반사)에도 구분되도록 기호를 같이 쓴다. + label(item) + when { + done -> " ✓" + partial -> " ·" + else -> "" + }, + fontSize = 13.sp, + fontWeight = if (on || done) FontWeight.Bold else FontWeight.Normal, + color = fg, ) } }