feat(clinical): 병원 임상 모드 — 어디까지 측정했는지 칩에 표시
간호사가 자세·방광 단계를 하나씩 올려 가며 재는데, 중간에 자리를 비우거나 순서가 꼬이면 "0% 했던가?"를 기억으로 판단하게 된다는 현장 의견. 그러면 한 단계를 통째로 빠뜨리거나(방광을 다시 채워야 한다) 같은 조건을 두 번 재게 된다. 화면이 대신 기억하게 한다. - 방광 단계 칩: 지금 고른 자세 안에서의 진행. 완료 ✓(초록) / 일부 ·(주황 테두리) - 자세 칩: 그 자세의 6단계가 전부 끝나야 완료 - 실행이 끝날 때마다 다시 읽어 방금 잰 단계가 즉시 채워진다 판정 근거는 **파일 존재가 아니라 실행 매니페스트**다. CSV 는 첫 회차만 저장돼도 만들어지므로, 파일 유무로 보면 20회 중 2회에서 끊긴 것도 "완료"로 보인다. 그게 제일 위험하다 — 다 됐다고 믿고 방광을 비우는데 데이터는 못 쓰고 그 단계는 그날 다시 못 잰다. 매니페스트의 조합별 planned/saved 로 "6조합이 전부 계획한 횟수를 채웠는가"를 본다. 중단된 실행도 매니페스트가 남으므로 PARTIAL 로 잡힌다. 완료/일부를 색뿐 아니라 기호(✓ ·)로도 구분한다 — 병실 조명·화면 반사에서 색만으로는 잘 안 보인다. 검증: 실기기(VBT26080001 연결)에서 오늘 09:40 실측 데이터로 확인. (Supine, 0%) → 0% 칩 ✓ · Supine 칩 · (6단계 중 1단계라 일부). 선택을 20% 로 옮기면 0% 가 초록 채움으로 남는 것까지 확인. 측정 경로는 건드리지 않았다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<Pair<String, Int>, RunProgress> {
|
||||
val dir = runDirIfExists(patient, day) ?: return emptyMap()
|
||||
val out = HashMap<Pair<String, Int>, 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
|
||||
}
|
||||
|
||||
@@ -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<Pair<String, Int>, 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 <T> ChipRow(
|
||||
items: List<T>, selected: T, onSelect: (T) -> Unit, enabled: Boolean, label: (T) -> String,
|
||||
items: List<T>,
|
||||
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 <T> 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user