Clinical: 잘못 측정한 값 폐기 — Discard(측정 중) + Delete(측정 후 + labdb 서버)
회의 결정: 잘못 측정한 경우를 없앨 방법이 없었음. 두 시점 모두 제공.
ClinicalSessionStore:
- discardAndDeleteFolder(): 측정 중 즉시 폐기. endMeasurement 호출 안 하고
currentSession + cyclesBuffer 비우고 폴더 전체 삭제.
- deleteFinalizedFolder(): lastFinalizedFolder 로컬 삭제 + reset.
LabdbClient.deleteSession(testId):
DELETE /api/v1/sessions/{testId} — Cascade로 모든 records 함께 제거.
204/200/404 모두 성공 처리 (404 = 이미 없음, 멱등).
401 → 자격 clear + Reregister, 403 → PendingApproval, 429 → RateLimit.
LabdbUploader.deleteSessionForFolder(folder):
labdb_upload.json에 sessionId 있으면 그것을, 없으면 sanitizeTestId(폴더명).
미업로드 폴더에 대해서도 안전하게 호출 가능 (404 처리).
ClinicalLiveView:
- 기존 End Session 단일 버튼 → Discard + End Session 두 버튼 (50/50 Row)
- Discard: 빨간 outline. confirm dialog → 폴더 삭제 + ClinicalHome 복귀
- 사이클 진행 중에도 누를 수 있음 (잘못 측정 즉시 인지 시)
ClinicalHomeView:
- Last saved 카드에 Upload 버튼 옆 (또는 단독) Delete 버튼 추가
- confirm dialog → labdb 서버 DELETE 시도 → 로컬 폴더 삭제 → state reset
- 서버 실패해도 로컬은 진행, Toast로 결과 표시
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -248,4 +248,32 @@ object ClinicalSessionStore {
|
|||||||
fun discardMeasurement() {
|
fun discardMeasurement() {
|
||||||
currentSession = null
|
currentSession = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 측정 중인 세션을 즉시 폐기 + 폴더 완전 삭제 (잘못 측정 인지 시).
|
||||||
|
* endMeasurement()와 달리 meta.json/measurement.json/labdb 업로드 전부 skip.
|
||||||
|
* 반환: 삭제된 폴더 경로 (없으면 null).
|
||||||
|
*/
|
||||||
|
fun discardAndDeleteFolder(): File? {
|
||||||
|
val dir = currentLogDir()
|
||||||
|
currentSession = null
|
||||||
|
cyclesBuffer.clear()
|
||||||
|
if (dir != null && dir.exists()) {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이미 finalize된 측정 폴더(lastFinalizedFolder)를 삭제.
|
||||||
|
* labdb 서버 데이터까지 제거하려면 호출자가 LabdbClient.deleteSession()도 별도 호출.
|
||||||
|
*/
|
||||||
|
fun deleteFinalizedFolder(): File? {
|
||||||
|
val dir = lastFinalizedFolder
|
||||||
|
if (dir != null && dir.exists()) {
|
||||||
|
dir.deleteRecursively()
|
||||||
|
}
|
||||||
|
lastFinalizedFolder = null
|
||||||
|
return dir
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
@@ -70,6 +70,22 @@ object LabdbUploader {
|
|||||||
return if (f.exists()) runCatching { JSONObject(f.readText()).optString("message") }.getOrNull() else null
|
return if (f.exists()) runCatching { JSONObject(f.readText()).optString("message") }.getOrNull() else null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴더에 해당하는 labdb 서버 세션 삭제.
|
||||||
|
* labdb_upload.json에 sessionId가 있으면 그것을 사용, 없으면 folderName → sanitizeTestId 변환.
|
||||||
|
* 미등록/미업로드 폴더의 testId도 그대로 호출 → labdb가 404로 응답 → 성공 처리.
|
||||||
|
*/
|
||||||
|
suspend fun deleteSessionForFolder(folder: File): Boolean {
|
||||||
|
val uploadMarker = File(folder, "labdb_upload.json")
|
||||||
|
val testId = if (uploadMarker.exists()) {
|
||||||
|
runCatching { JSONObject(uploadMarker.readText()).optString("sessionId") }
|
||||||
|
.getOrNull()?.takeIf { it.isNotBlank() } ?: sanitizeTestId(folder.name)
|
||||||
|
} else {
|
||||||
|
sanitizeTestId(folder.name)
|
||||||
|
}
|
||||||
|
return if (testId.isNotBlank()) LabdbClient.deleteSession(testId) else true
|
||||||
|
}
|
||||||
|
|
||||||
/** measurement.json → labdb payload. 각 cycle = 1 record. */
|
/** measurement.json → labdb payload. 각 cycle = 1 record. */
|
||||||
private fun buildPayload(folderName: String, measurement: JSONObject): JSONObject {
|
private fun buildPayload(folderName: String, measurement: JSONObject): JSONObject {
|
||||||
val meta = measurement.optJSONObject("meta") ?: JSONObject()
|
val meta = measurement.optJSONObject("meta") ?: JSONObject()
|
||||||
|
|||||||
+73
-26
@@ -85,6 +85,8 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
}
|
}
|
||||||
var uploadBusy by remember { mutableStateOf(false) }
|
var uploadBusy by remember { mutableStateOf(false) }
|
||||||
var showUnpairConfirm by remember { mutableStateOf(false) }
|
var showUnpairConfirm by remember { mutableStateOf(false) }
|
||||||
|
var showDeleteSavedConfirm by remember { mutableStateOf(false) }
|
||||||
|
var deleteBusy by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
// 진입 시 status 자동 확인 (apiKey 있을 때만)
|
// 진입 시 status 자동 확인 (apiKey 있을 때만)
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
@@ -295,32 +297,43 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!lastUploaded && LabdbCredentials.isRegistered) {
|
Column(horizontalAlignment = Alignment.End,
|
||||||
Button(
|
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
onClick = {
|
if (!lastUploaded && LabdbCredentials.isRegistered) {
|
||||||
if (uploadBusy) return@Button
|
Button(
|
||||||
uploadBusy = true
|
onClick = {
|
||||||
scope.launch {
|
if (uploadBusy) return@Button
|
||||||
runCatching { LabdbUploader.uploadBlocking(lastFolder) }
|
uploadBusy = true
|
||||||
.onSuccess {
|
scope.launch {
|
||||||
lastUploaded = true
|
runCatching { LabdbUploader.uploadBlocking(lastFolder) }
|
||||||
lastUploadError = null
|
.onSuccess {
|
||||||
Toast.makeText(context,
|
lastUploaded = true
|
||||||
"Uploaded: ${it.inserted}/${it.totalRecords}",
|
lastUploadError = null
|
||||||
Toast.LENGTH_SHORT).show()
|
Toast.makeText(context,
|
||||||
}
|
"Uploaded: ${it.inserted}/${it.totalRecords}",
|
||||||
.onFailure {
|
Toast.LENGTH_SHORT).show()
|
||||||
lastUploadError = it.message ?: it.javaClass.simpleName
|
}
|
||||||
// status도 갱신 (401/403/404 케이스 반영)
|
.onFailure {
|
||||||
labdbApiKey = LabdbCredentials.apiKey
|
lastUploadError = it.message ?: it.javaClass.simpleName
|
||||||
labdbStatus = LabdbCredentials.lastStatus ?: "unknown"
|
// status도 갱신 (401/403/404 케이스 반영)
|
||||||
}
|
labdbApiKey = LabdbCredentials.apiKey
|
||||||
uploadBusy = false
|
labdbStatus = LabdbCredentials.lastStatus ?: "unknown"
|
||||||
}
|
}
|
||||||
},
|
uploadBusy = false
|
||||||
enabled = !uploadBusy,
|
}
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
},
|
||||||
) { Text("Upload", color = Color.White, fontSize = 12.sp) }
|
enabled = !uploadBusy,
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
|
||||||
|
) { Text("Upload", color = Color.White, fontSize = 12.sp) }
|
||||||
|
}
|
||||||
|
// Delete — 잘못 측정 시 폐기 (로컬 + 서버)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showDeleteSavedConfirm = true },
|
||||||
|
enabled = !deleteBusy && !uploadBusy,
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935))
|
||||||
|
) { Text("Delete", fontSize = 12.sp) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -446,6 +459,40 @@ fun ClinicalHomeView(appState: AppState) {
|
|||||||
fontSize = 10.sp, color = MlSecondaryText)
|
fontSize = 10.sp, color = MlSecondaryText)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 잘못 측정한 폴더 삭제 confirm — 로컬 + labdb 서버 둘 다 (회의 결정: Yes 서버도)
|
||||||
|
if (showDeleteSavedConfirm && lastFolder != null) {
|
||||||
|
val targetFolder = lastFolder
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDeleteSavedConfirm = false },
|
||||||
|
title = { Text("Delete measurement?") },
|
||||||
|
text = { Text("폴더 + labdb 서버 데이터를 모두 삭제합니다. 복구 불가.\n\n${targetFolder.name}") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
if (deleteBusy) return@TextButton
|
||||||
|
showDeleteSavedConfirm = false
|
||||||
|
deleteBusy = true
|
||||||
|
scope.launch {
|
||||||
|
// 1) labdb 서버 삭제 시도 (실패해도 로컬은 진행)
|
||||||
|
val serverResult = runCatching {
|
||||||
|
LabdbUploader.deleteSessionForFolder(targetFolder)
|
||||||
|
}
|
||||||
|
// 2) 로컬 폴더 삭제
|
||||||
|
ClinicalSessionStore.deleteFinalizedFolder()
|
||||||
|
lastUploaded = false
|
||||||
|
lastUploadError = null
|
||||||
|
val msg = if (serverResult.isSuccess) "Deleted (local + server)"
|
||||||
|
else "Local deleted, server failed: ${serverResult.exceptionOrNull()?.message}"
|
||||||
|
Toast.makeText(context, msg, Toast.LENGTH_LONG).show()
|
||||||
|
deleteBusy = false
|
||||||
|
}
|
||||||
|
}) { Text("Delete", color = Color.Red) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDeleteSavedConfirm = false }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (showUnpairConfirm) {
|
if (showUnpairConfirm) {
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { showUnpairConfirm = false },
|
onDismissRequest = { showUnpairConfirm = false },
|
||||||
|
|||||||
+50
-14
@@ -271,21 +271,57 @@ fun ClinicalLiveView(appState: AppState) {
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
// End Session
|
// End Session / Discard 두 버튼 나란히
|
||||||
Button(
|
var showDiscardConfirm by remember { mutableStateOf(false) }
|
||||||
onClick = {
|
Row(modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||||
isMeasuring = false
|
horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
val folder = ClinicalSessionStore.endMeasurement(context)
|
// Discard — 측정값 폐기 + 폴더 삭제 (저장 안 함)
|
||||||
folder?.let {
|
OutlinedButton(
|
||||||
Toast.makeText(context, "Saved ($capturedCount cycles): ${it.name}",
|
onClick = { showDiscardConfirm = true },
|
||||||
Toast.LENGTH_LONG).show()
|
modifier = Modifier.weight(1f).height(48.dp),
|
||||||
|
colors = ButtonDefaults.outlinedButtonColors(contentColor = Color(0xFFE53935))
|
||||||
|
) {
|
||||||
|
Text("Discard", fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
// End Session — 저장 + 자동 업로드
|
||||||
|
Button(
|
||||||
|
onClick = {
|
||||||
|
isMeasuring = false
|
||||||
|
val folder = ClinicalSessionStore.endMeasurement(context)
|
||||||
|
folder?.let {
|
||||||
|
Toast.makeText(context, "Saved ($capturedCount cycles): ${it.name}",
|
||||||
|
Toast.LENGTH_LONG).show()
|
||||||
|
}
|
||||||
|
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f).height(48.dp),
|
||||||
|
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.85f))
|
||||||
|
) {
|
||||||
|
Text("End Session", color = Color.White, fontWeight = FontWeight.Bold)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showDiscardConfirm) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = { showDiscardConfirm = false },
|
||||||
|
title = { Text("Discard measurement?") },
|
||||||
|
text = { Text("현재 사이클 ${capturedCount}개 + 폴더 전체 삭제. labdb 업로드 안 함. 복구 불가.") },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
showDiscardConfirm = false
|
||||||
|
isMeasuring = false
|
||||||
|
val deleted = ClinicalSessionStore.discardAndDeleteFolder()
|
||||||
|
deleted?.let {
|
||||||
|
Toast.makeText(context, "Discarded: ${it.name}",
|
||||||
|
Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
appState.currentScreen = AppScreen.CLINICAL_HOME
|
||||||
|
}) { Text("Discard", color = Color.Red) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showDiscardConfirm = false }) { Text("Cancel") }
|
||||||
}
|
}
|
||||||
appState.currentScreen = AppScreen.CLINICAL_HOME
|
)
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp).height(48.dp),
|
|
||||||
colors = ButtonDefaults.buttonColors(containerColor = Color.Red.copy(alpha = 0.85f))
|
|
||||||
) {
|
|
||||||
Text("End Session", color = Color.White, fontWeight = FontWeight.Bold)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|||||||
Reference in New Issue
Block a user