diff --git a/app/src/main/java/com/medithings/vesiscan/services/ClinicalSessionStore.kt b/app/src/main/java/com/medithings/vesiscan/services/ClinicalSessionStore.kt index 79ff296..8c0fc77 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/ClinicalSessionStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/ClinicalSessionStore.kt @@ -197,8 +197,9 @@ object ClinicalSessionStore { metaJsonFile(dir).writeText(meta.toString(2)) } - /** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → currentSession=null. - * labdb 업로드는 자동 X — Clinical Home 의 'Last saved' 카드의 Upload 버튼으로 수동 트리거. */ + /** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → + * labdb 자동 업로드 (apiKey 있을 때만, fire-and-forget) → currentSession=null. + * 실패 폴더는 Clinical Home 재진입 시 LabdbAutoRetry 가 자동 재시도. */ fun endMeasurement(context: Context): File? { val s = currentSession ?: return null s.endedAt = System.currentTimeMillis() @@ -208,6 +209,14 @@ object ClinicalSessionStore { lastFinalizedFolder = dir currentSession = null cyclesBuffer.clear() + // 2026-07-09: 자동 업로드 추가. apiKey 만 있으면 시도 (status 는 서버 응답으로 판단). + // 실패해도 폴더는 그대로 유지 → 다음 ClinicalHome 진입 시 LabdbAutoRetry 재시도. + if (dir != null) { + val creds = com.medithings.vesiscan.services.labdb.LabdbCredentials + if (creds.isRegistered) { + com.medithings.vesiscan.services.labdb.LabdbUploader.uploadAsync(context, dir) + } + } return dir } diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbAutoRetry.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbAutoRetry.kt new file mode 100644 index 0000000..a2586ad --- /dev/null +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/LabdbAutoRetry.kt @@ -0,0 +1,96 @@ +package com.medithings.vesiscan.services.labdb + +import android.content.Context +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import java.io.File + +/** + * labdb 미업로드 세션 자동 재시도. + * + * ClinicalHomeView 진입 시 retryPending(context) 를 호출하면: + * - ClinicalSessionStore.lastFinalizedFolder 를 스캔 + * - labdb_upload.json 마커가 없는 폴더만 대상 + * - LabdbUploader.uploadBlocking 을 순차 실행 + * - 진행 상황을 pendingCount / uploadingName / lastResultMessage state 로 노출 + * - UI (ClinicalHomeView) 가 관찰해 배너 표시 + * + * demo-final 은 recentFinalizedFolders 히스토리 구조가 없어 lastFinalizedFolder + * 하나만 대상. 사용자가 세션 완료 → Clinical Home 재진입 → 자동 재업로드 → 다음 + * 세션 진행하며 자연스럽게 순차 처리 가능. + * + * 조건: + * - LabdbCredentials.isRegistered = true (apiKey 존재) + * - 폴더에 measurement_.json 또는 measurement.json 존재 + * + * 실패한 폴더는 labdb_upload_error.json 만 남기고 계속 진행. 다음 호출 시 재시도. + */ +object LabdbAutoRetry { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + + /** 재시도 대기 중인 폴더 수 (0 = 없음). UI 배너 표시용. */ + val pendingCount = mutableIntStateOf(0) + /** 현재 업로드 중인 세션 이름 (null = idle). */ + val uploadingName = mutableStateOf(null) + /** 마지막 배치 종료 후 요약 메시지. null = 표시 안 함. */ + val lastResultMessage = mutableStateOf(null) + /** 동시 실행 방지. */ + @Volatile private var running = false + + /** + * 최근 finalize 된 세션 중 미업로드 폴더를 자동 재시도. + * 이미 실행 중이면 no-op. 완료 후 lastResultMessage 세팅. + */ + fun retryPending(context: Context) { + if (running) return + if (!LabdbCredentials.isRegistered) return + + val last = com.medithings.vesiscan.services.ClinicalSessionStore.lastFinalizedFolder + val recent = listOfNotNull(last) + val pending = recent.filter { needsUpload(it) } + if (pending.isEmpty()) return + + running = true + pendingCount.intValue = pending.size + lastResultMessage.value = null + + scope.launch { + var uploaded = 0 + var failed = 0 + for (folder in pending) { + uploadingName.value = folder.name + val ok = runCatching { LabdbUploader.uploadBlocking(folder) }.isSuccess + if (ok) uploaded++ else failed++ + pendingCount.intValue = (pending.size - (uploaded + failed)).coerceAtLeast(0) + } + uploadingName.value = null + lastResultMessage.value = buildString { + append("Auto upload: ") + append("$uploaded uploaded") + if (failed > 0) append(", $failed failed") + } + running = false + } + } + + /** 폴더가 measurement.json 을 가지고 있으면서 labdb_upload.json 마커가 없는지. */ + private fun needsUpload(folder: File): Boolean { + if (!folder.exists() || !folder.isDirectory) return false + if (File(folder, "labdb_upload.json").exists()) return false + val hasNamed = File(folder, "measurement_${folder.name}.json").exists() + val hasLegacy = File(folder, "measurement.json").exists() + val hasAny = folder.listFiles()?.any { + it.name.startsWith("measurement_") && it.name.endsWith(".json") + } == true + return hasNamed || hasLegacy || hasAny + } + + /** 배너 dismiss — 사용자 확인 후 lastResultMessage 지움. */ + fun dismissResult() { + lastResultMessage.value = null + } +} diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalHomeView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalHomeView.kt index 79c3f8c..8e176b5 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalHomeView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/ClinicalHomeView.kt @@ -12,6 +12,7 @@ import androidx.compose.material.icons.filled.MyLocation import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Warning import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material3.* import androidx.compose.runtime.* @@ -110,6 +111,16 @@ fun ClinicalHomeView(appState: AppState) { } } + // 2026-07-09: ClinicalHome 진입 시 미업로드 세션 자동 재시도. + // endMeasurement 자동 업로드가 네트워크 순간 이슈 등으로 실패한 폴더를 다음 진입 + // 시점에 자동으로 다시 올림 — 사용자가 업로드 버튼 누르는 걸 잊어도 결국 반영됨. + LaunchedEffect(Unit) { + com.medithings.vesiscan.services.labdb.LabdbAutoRetry.retryPending(context) + } + val autoUploadPending by com.medithings.vesiscan.services.labdb.LabdbAutoRetry.pendingCount + val autoUploadingName by com.medithings.vesiscan.services.labdb.LabdbAutoRetry.uploadingName + val autoUploadResult by com.medithings.vesiscan.services.labdb.LabdbAutoRetry.lastResultMessage + Column( modifier = Modifier .fillMaxSize() @@ -131,6 +142,56 @@ fun ClinicalHomeView(appState: AppState) { Spacer(modifier = Modifier.height(16.dp)) + // 2026-07-09: 자동 업로드 진행/결과 배너. + if (autoUploadPending > 0 || autoUploadingName != null) { + Row( + modifier = Modifier.fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(Color(0xFF1976D2).copy(alpha = 0.12f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = Color(0xFF1976D2) + ) + Spacer(Modifier.width(10.dp)) + Column { + Text("자동 업로드 진행 중 · 남은 세션 $autoUploadPending", + fontSize = 13.sp, fontWeight = FontWeight.SemiBold, + color = Color(0xFF1976D2)) + autoUploadingName?.let { + Text(it, fontSize = 11.sp, color = Color(0xFF1976D2).copy(alpha = 0.8f), + maxLines = 1) + } + } + } + Spacer(modifier = Modifier.height(8.dp)) + } else if (autoUploadResult != null) { + val msg = autoUploadResult ?: "" + val hasFail = msg.contains("failed") + val bg = if (hasFail) Color(0xFFFF9800) else Color(0xFF4CAF50) + Row( + modifier = Modifier.fillMaxWidth() + .clip(RoundedCornerShape(10.dp)) + .background(bg.copy(alpha = 0.15f)) + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + if (hasFail) Icons.Filled.Warning else Icons.Filled.CheckCircle, + contentDescription = null, tint = bg, modifier = Modifier.size(16.dp)) + Spacer(Modifier.width(10.dp)) + Text(msg, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, color = bg, + modifier = Modifier.weight(1f)) + TextButton(onClick = { + com.medithings.vesiscan.services.labdb.LabdbAutoRetry.dismissResult() + }) { Text("확인", color = bg, fontSize = 12.sp) } + } + Spacer(modifier = Modifier.height(8.dp)) + } + // BLE 연결 상태 Card( shape = RoundedCornerShape(10.dp),