diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbUploader.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbUploader.kt index af8f60b..36b199f 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbUploader.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbUploader.kt @@ -120,6 +120,14 @@ object LabdbUploader { return JSONObject().apply { put("testId", testId) put("dataType", dataType) + // 서버(server.js:650)는 **최상위 subject 만** 읽어 sessions.subject 컬럼에 + // 넣는다 — params 안은 보지 않는다. 그래서 기존 세션의 그 컬럼이 전부 비어 + // 있었다(2026-09-09 서버 조사). 그 컬럼이 export 파일명 prefix 와 세션 목록 + // 표시에 쓰이므로, 비면 분석하는 쪽이 testId 를 눈으로 파싱해야 한다. + // + // params 안에도 그대로 둔다. 이미 올라간 세션들이 그 자리에 있어, 빼면 + // 과거·신규 세션을 같은 규칙으로 읽을 수 없다. + meta.optString("subject").takeIf { it.isNotBlank() }?.let { put("subject", it) } // 서버는 sessionName을 export 파일명의 base로 사용 (날짜 포함이면 중복 안 붙임). // 풀 폴더명을 그대로 보내야 분석 PC 다운로드 파일명과 앱 측 파일명이 일치. put("sessionName", folderName) diff --git a/app/src/main/java/com/medithings/vesiscan/ui/components/LabdbStatusCard.kt b/app/src/main/java/com/medithings/vesiscan/ui/components/LabdbStatusCard.kt new file mode 100644 index 0000000..39f25b1 --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/ui/components/LabdbStatusCard.kt @@ -0,0 +1,147 @@ +/* + * LabdbStatusCard — labdb 등록 상태와 등록 버튼. 임상 화면 어디서든 붙인다. + */ +package com.medithings.vesiscan.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +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.services.labdb.LabdbClient +import com.medithings.vesiscan.services.labdb.LabdbCredentials +import com.medithings.vesiscan.ui.theme.* +import kotlinx.coroutines.launch + +/** + * labdb 등록 상태 — **미등록이면 반드시 눈에 띄어야 한다.** + * + * ## 왜 이 카드가 필요한가 + * 종전에는 등록 버튼이 임상 측정 홈([views.clinical.ClinicalHomeView])에만 있었다. + * 그런데 병원 임상 모드는 홈 화면에서 **직접** 들어가므로 그곳을 거치지 않는다. 게다가 + * 병원 모드의 업로드 카드가 `isRegistered` 로 감싸져 있어, 미등록이면 카드가 아예 안 + * 보였다 — 조작자는 "이 화면에는 업로드 기능이 없다"로 읽는다. + * + * 그 결과 2026-09-09 실기기에서 임상 세션 3개와 정렬 1세트를 받았는데 **업로드 시도 + * 기록조차 없었다**(성공·실패 마커가 둘 다 없었다). 자동 업로드가 `isRegistered` 뒤에 + * 있어 아무 일도 일어나지 않은 것이다. + * + * 측정을 시작하기 **전에** 알아야 한다. 20 cycle × 조합을 다 받은 뒤에 알면 그 데이터는 + * 폰에만 남고, 방광을 비운 뒤라 다시 잴 수도 없다. + * + * ## 상태가 셋이다 + * | | 뜻 | 업로드 | + * |---|---|---| + * | 미등록 | apiKey 없음 | **안 됨** — 시도조차 안 한다 | + * | 승인 대기(`pending`) | 등록됨, 관리자 승인 전 | 안 됨 — 서버가 거절 | + * | 활성(`active`) | 승인됨 | 됨 | + * + * **"등록했다"와 "올라간다"는 다르다.** 등록 직후는 `pending` 이라, 버튼을 누른 것만으로 + * 끝났다고 읽으면 같은 일이 반복된다. 그래서 승인 대기도 경고 색으로 둔다. + */ +@Composable +fun LabdbStatusCard( + modifier: Modifier = Modifier, + /** 측정 중에는 막는다 — 등록 요청이 BLE 정보를 읽어 가므로 루프와 겹치지 않게 한다. */ + enabled: Boolean = true, +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var registered by remember { mutableStateOf(LabdbCredentials.isRegistered) } + var status by remember { mutableStateOf(LabdbCredentials.lastStatus) } + var busy by remember { mutableStateOf(false) } + var message by remember { mutableStateOf(null) } + + // 등록되어 있으면 진입 때 상태를 한 번 확인한다. 승인이 떨어졌는지 조작자가 알아야 + // 하고, 기기가 삭제됐으면(404) checkStatus 가 자격을 비우므로 그 사실도 즉시 보인다. + LaunchedEffect(Unit) { + if (!LabdbCredentials.isRegistered) return@LaunchedEffect + busy = true + runCatching { LabdbClient.checkStatus() } + .onSuccess { status = it.status } + .onFailure { message = it.message } + registered = LabdbCredentials.isRegistered + busy = false + } + + val active = registered && status == "active" + val tone = if (active) MlSuccess else MlWarning + Column( + modifier.fillMaxWidth() + .background(tone.copy(alpha = 0.10f), RoundedCornerShape(12.dp)) + .border(1.dp, tone.copy(alpha = 0.5f), RoundedCornerShape(12.dp)) + .padding(12.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text( + when { + !registered -> "labdb 미등록 — 업로드되지 않습니다" + active -> "labdb 연결됨" + status == "pending" -> "labdb 승인 대기 — 아직 업로드되지 않습니다" + status == "revoked" -> "labdb 권한 해제됨 — 업로드되지 않습니다" + else -> "labdb 상태 ${status ?: "확인 안 됨"}" + }, + fontSize = 14.sp, fontWeight = FontWeight.Bold, color = tone, + ) + Text( + when { + !registered -> + "지금 측정하면 데이터가 폰에만 남습니다. 아래 버튼으로 이 기기를 등록하세요." + active -> "조합이 끝날 때마다 자동으로 올라갑니다." + status == "pending" -> + "관리자가 이 기기를 승인해야 올라갑니다. 승인 전 측정분은 폰에 쌓여 있다가 " + + "승인 뒤 [지금 업로드] 로 한꺼번에 보낼 수 있습니다." + else -> "관리자에게 문의하세요. 측정분은 폰에 남습니다." + }, + fontSize = 11.sp, color = MlSecondaryText, + ) + LabdbCredentials.deviceId?.let { + Text("deviceId $it", fontSize = 10.sp, color = MlSecondaryText) + } + } + if (!registered) { + Spacer(Modifier.width(8.dp)) + Button( + onClick = { + if (busy) return@Button + busy = true; message = null + scope.launch { + val version = runCatching { + context.packageManager + .getPackageInfo(context.packageName, 0).versionName + }.getOrNull() ?: "unknown" + runCatching { LabdbClient.register(context, version) } + .onSuccess { + registered = true + status = it.status + // 서버 문구를 그대로 보여 준다. 승인 절차가 바뀌어도 + // 앱이 옛 안내를 하지 않는다. + message = it.message.ifBlank { "등록됨 — 승인 대기" } + } + .onFailure { message = "등록 실패 — ${it.message}" } + busy = false + } + }, + enabled = enabled && !busy, + colors = ButtonDefaults.buttonColors(containerColor = MlPrimary), + shape = RoundedCornerShape(10.dp), + ) { Text(if (busy) "등록 중…" else "labdb 등록", color = Color.White, fontSize = 12.sp) } + } + } + message?.let { + Spacer(Modifier.height(6.dp)) + Text(it, fontSize = 11.sp, color = MlSecondaryText) + } + } +} 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 99e2c7a..67b5eff 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 @@ -41,6 +41,7 @@ import androidx.compose.ui.unit.sp import com.medithings.vesiscan.managers.AnchorBasis import com.medithings.vesiscan.managers.ClinicalBv import com.medithings.vesiscan.ui.components.ChannelDetachPanel +import com.medithings.vesiscan.ui.components.LabdbStatusCard import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid import com.medithings.vesiscan.AppScreen import com.medithings.vesiscan.AppState @@ -412,6 +413,14 @@ fun HospitalModeView(appState: AppState) { ) Spacer(Modifier.height(12.dp)) + // labdb 등록 상태. **측정 전에** 보여야 한다 — 미등록이면 업로드 시도조차 + // 일어나지 않는데(ClinicalSessionStore·HospitalLabdbUploader 가 둘 다 + // isRegistered 뒤에 있다), 종전에는 업로드 카드가 isRegistered 로 감싸져 + // 있어 미등록일 때 화면에 아무것도 없었다. 20 cycle × 조합을 받은 뒤에 + // 알면 방광을 비운 뒤라 다시 잴 수 없다. + LabdbStatusCard(enabled = !running) + Spacer(Modifier.height(12.dp)) + AnchorCard( anchorCm = appState.anchorCm, basis = appState.anchorBasis, @@ -558,7 +567,10 @@ fun HospitalModeView(appState: AppState) { // 조합이 끝날 때마다 자동으로 올라간다. 인터넷이 끊겨 있었으면 여기 // 남아 있다가 이 버튼으로 한꺼번에 올린다 — 병원 무선망이 불안정한 // 곳이 많아 "나중에 올리기"가 예외가 아니라 정상 경로다. - if (LabdbCredentials.isRegistered && lastRunDir != null) { + // isRegistered 로 감싸지 않는다. 미등록일 때도 **몇 건이 못 올라갔는지**는 + // 보여야 한다 — 승인 뒤 [지금 업로드] 로 한꺼번에 보내야 하므로, 그 수를 + // 모르면 무엇을 기다리는지 알 수 없다. + if (lastRunDir != null) { Spacer(Modifier.height(12.dp)) Box( Modifier.fillMaxWidth() @@ -591,7 +603,9 @@ fun HospitalModeView(appState: AppState) { uploadBusy = false } }, - enabled = !uploadBusy, + // 미등록 상태로 누르면 전부 실패 마커가 쌓이고, 그 + // 마커 때문에 나중에 성공 여부를 헷갈리게 된다. + enabled = !uploadBusy && LabdbCredentials.isRegistered, modifier = Modifier.fillMaxWidth().height(46.dp), shape = RoundedCornerShape(12.dp), ) {