Clinical: labdb.medithings.net REST 업로드 통합 (Phase A+B)

End Session 시 measurement.json을 https://labdb.medithings.net/api/v1/upload/json
에 자동 업로드. dataType="000" (VesiScan 6채널). cycle 1개 = record 1개.
testId는 폴더명 그대로 사용 (40자 초과 시 prefix+hash로 축약).

Files:
  - LabdbCredentials: apiKey/deviceId를 EncryptedSharedPreferences에 안전 저장
  - LabdbClient: register/checkStatus/uploadJson + 5가지 에러 분기
    (PendingApproval / Reregister / RateLimit / ApiError / Network)
  - LabdbUploader: measurement.json → payload 변환 + fire-and-forget 업로드
    성공 시 labdb_upload.json, 실패 시 labdb_upload_error.json 마커 작성

ClinicalSessionStore.endMeasurement: cycle>0 + active 상태일 때만 자동 트리거.
ClinicalHomeView: labdb 상태 카드(Register/Refresh) + 직전 측정 업로드 상태
표시 + 수동 Upload 재시도 버튼.

오프라인 큐는 아직 없음 (Phase C는 후속).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-01 12:31:56 +09:00
parent 6f9ce00c65
commit b3d9c16780
6 changed files with 2147 additions and 13 deletions
@@ -139,16 +139,25 @@ object ClinicalSessionStore {
File(dir, "meta.json").writeText(meta.toString(2))
}
/** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 → currentSession=null */
/** 측정 종료: endedAt → meta.json 갱신 → measurement.json 통합 생성 →
* labdb 자동 업로드 (apiKey 있고 active 상태일 때만) → currentSession=null */
fun endMeasurement(context: Context): File? {
val s = currentSession ?: return null
s.endedAt = System.currentTimeMillis()
writeMeta(context)
writeMeasurementJson(context)
val capturedCount = cyclesBuffer.size
val dir = currentLogDir()
lastFinalizedFolder = dir
currentSession = null
cyclesBuffer.clear()
// 자동 업로드 — fire-and-forget. 실패해도 폴더는 그대로, 수동 재시도 가능.
if (dir != null && capturedCount > 0) {
val creds = com.example.medilightv2android.services.labdb.LabdbCredentials
if (creds.isRegistered && creds.lastStatus == "active") {
com.example.medilightv2android.services.labdb.LabdbUploader.uploadAsync(context, dir)
}
}
return dir
}
@@ -0,0 +1,167 @@
package com.example.medilightv2android.services.labdb
import android.content.Context
import android.os.Build
import com.example.medilightv2android.ble.BleManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.io.IOException
import java.util.concurrent.TimeUnit
sealed class LabdbException(message: String) : IOException(message)
class PendingApprovalException : LabdbException("Device is awaiting administrator approval")
class ReregisterRequiredException(message: String) : LabdbException(message)
class RateLimitedException : LabdbException("Rate limit exceeded (10 req/min)")
class LabdbApiError(val httpStatus: Int, val code: String, message: String) : LabdbException("$code ($httpStatus): $message")
class LabdbNetworkError(cause: Throwable) : LabdbException("Network error: ${cause.message}")
data class RegisterResult(val deviceId: String, val apiKey: String, val status: String, val message: String)
data class StatusResult(val httpStatus: Int, val status: String, val deviceName: String?, val sessionCount: Int?)
data class UploadResult(
val ok: Boolean,
val sessionId: String,
val created: Boolean,
val totalRecords: Int,
val inserted: Int,
val duplicates: Int,
val errors: Int,
)
/**
* https://labdb.medithings.net REST 클라이언트.
*
* 사용 순서:
* 1) register(context, appVersion) — 앱 첫 임상 모드 진입 시 1회
* 2) checkStatus() — 임상 화면 진입 시 매번
* 3) uploadJson(payload) — End Session 후 measurement.json 업로드
*
* 모든 메서드는 suspend, IO 디스패처에서 실행.
*/
object LabdbClient {
private const val BASE = "https://labdb.medithings.net/api/v1"
private const val APP_NAME = "VesiScan"
private val JSON = "application/json; charset=utf-8".toMediaType()
private val client: OkHttpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build()
}
/** 디바이스 등록 — 앱 생애 1회. 응답에 status='pending'이면 admin 승인 대기. */
suspend fun register(context: Context, appVersion: String): RegisterResult = withContext(Dispatchers.IO) {
val ble = BleManager.getInstance(context)
val body = JSONObject().apply {
put("appName", APP_NAME)
put("deviceName", "${Build.MANUFACTURER} ${Build.MODEL}")
ble.firmwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("fwVersion", it) }
ble.hardwareVersion.value.takeIf { it.isNotEmpty() }?.let { put("hwNumber", it) }
ble.serialNumber.value.takeIf { it.isNotEmpty() }?.let { put("serialNumber", it) }
put("appVersion", appVersion)
}
val req = Request.Builder()
.url("$BASE/devices/register")
.post(body.toString().toRequestBody(JSON))
.build()
val (code, json) = exec(req)
when (code) {
200, 201 -> {
val deviceId = json.optString("deviceId")
val apiKey = json.optString("apiKey")
val status = json.optString("status", "pending")
val message = json.optString("message", "")
if (apiKey.isBlank()) throw LabdbApiError(code, "INVALID_RESPONSE", "register response missing apiKey")
LabdbCredentials.apiKey = apiKey
LabdbCredentials.deviceId = deviceId
LabdbCredentials.lastStatus = status
RegisterResult(deviceId, apiKey, status, message)
}
else -> throw apiError(code, json)
}
}
/** 디바이스 상태 확인. apiKey 무효 시 자격 자동 clear. */
suspend fun checkStatus(): StatusResult = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: return@withContext StatusResult(0, "not_registered", null, null)
val req = Request.Builder()
.url("$BASE/devices/status")
.header("X-API-Key", key)
.get()
.build()
val (code, json) = exec(req)
when (code) {
200 -> {
val status = json.optString("status", "unknown")
LabdbCredentials.lastStatus = status
StatusResult(
httpStatus = 200,
status = status,
deviceName = json.optString("deviceName").ifBlank { null },
sessionCount = if (json.has("sessionCount")) json.optInt("sessionCount") else null,
)
}
401, 404 -> {
LabdbCredentials.clear()
val err = json.optJSONObject("error")
val errCode = err?.optString("code") ?: if (code == 401) "INVALID_API_KEY" else "DEVICE_DELETED"
throw ReregisterRequiredException("$errCode: server requires re-registration")
}
else -> throw apiError(code, json)
}
}
/** /upload/json — measurement.json payload 한 번에 전송. testId+rowIndex 기반 idempotent. */
suspend fun uploadJson(payload: JSONObject): UploadResult = withContext(Dispatchers.IO) {
val key = LabdbCredentials.apiKey
?: throw ReregisterRequiredException("No apiKey — register first")
val req = Request.Builder()
.url("$BASE/upload/json")
.header("X-API-Key", key)
.post(payload.toString().toRequestBody(JSON))
.build()
val (code, json) = exec(req)
when (code) {
200, 201 -> UploadResult(
ok = json.optBoolean("ok", true),
sessionId = json.optString("sessionId"),
created = json.optBoolean("created", code == 201),
totalRecords = json.optInt("totalRecords"),
inserted = json.optInt("inserted"),
duplicates = json.optInt("duplicates"),
errors = json.optInt("errors"),
)
401 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("INVALID_API_KEY") }
403 -> throw PendingApprovalException()
404 -> { LabdbCredentials.clear(); throw ReregisterRequiredException("DEVICE_DELETED") }
429 -> throw RateLimitedException()
else -> throw apiError(code, json)
}
}
private fun apiError(code: Int, json: JSONObject): LabdbApiError {
val err = json.optJSONObject("error")
val errCode = err?.optString("code") ?: "HTTP_$code"
val msg = err?.optString("message") ?: json.toString().take(200)
return LabdbApiError(code, errCode, msg)
}
private fun exec(req: Request): Pair<Int, JSONObject> {
return try {
client.newCall(req).execute().use { res ->
val raw = res.body?.string().orEmpty()
val obj = if (raw.isBlank()) JSONObject() else runCatching { JSONObject(raw) }.getOrElse { JSONObject() }
res.code to obj
}
} catch (t: IOException) {
throw LabdbNetworkError(t)
}
}
}
@@ -0,0 +1,46 @@
package com.example.medilightv2android.services.labdb
import com.example.medilightv2android.services.KeychainService
/**
* labdb (https://labdb.medithings.net) 자격 증명 저장소.
*
* apiKey/deviceId는 KeychainService(EncryptedSharedPreferences)에 안전 저장.
* 등록은 앱 첫 임상 모드 진입 시 1회만 — 재등록 시 pending device 누적되므로
* 401 INVALID_API_KEY / 404 DEVICE_DELETED 응답을 받았을 때만 clear() 후 재등록.
*/
object LabdbCredentials {
private const val KEY_API_KEY = "labdb.apiKey"
private const val KEY_DEVICE_ID = "labdb.deviceId"
private const val KEY_LAST_STATUS = "labdb.lastStatus"
var apiKey: String?
get() = KeychainService.load(KEY_API_KEY)
set(value) {
if (value == null) KeychainService.delete(KEY_API_KEY)
else KeychainService.save(KEY_API_KEY, value)
}
var deviceId: String?
get() = KeychainService.load(KEY_DEVICE_ID)
set(value) {
if (value == null) KeychainService.delete(KEY_DEVICE_ID)
else KeychainService.save(KEY_DEVICE_ID, value)
}
/** 마지막으로 GET /devices/status 응답에서 받은 상태 ("active"/"pending"/"revoked") */
var lastStatus: String?
get() = KeychainService.load(KEY_LAST_STATUS)
set(value) {
if (value == null) KeychainService.delete(KEY_LAST_STATUS)
else KeychainService.save(KEY_LAST_STATUS, value)
}
val isRegistered: Boolean get() = !apiKey.isNullOrBlank()
fun clear() {
apiKey = null
deviceId = null
lastStatus = null
}
}
@@ -0,0 +1,202 @@
package com.example.medilightv2android.services.labdb
import android.content.Context
import android.util.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.security.MessageDigest
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* 측정 폴더(measurement.json 포함) → labdb /upload/json 자동 업로드.
*
* 정책:
* - End Session 시 ClinicalSessionStore가 uploadAsync(folder)를 호출
* - 성공: folder/labdb_upload.json (응답 본문)
* - 실패: folder/labdb_upload_error.json (에러 본문) — 폴더는 그대로 유지되어 수동 재시도 가능
*
* dataType = "000" (VesiScan 6채널). vesiscan_test 프로토콜과 동일.
*
* testId:
* - 폴더명 그대로 사용 (사용자 결정). 단 ^[A-Za-z0-9_-]{1,40}$ 제한 때문에
* 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix로 축약(unique 유지).
*/
object LabdbUploader {
private const val TAG = "LabdbUploader"
private const val DATA_TYPE = "000"
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val isoFmt: SimpleDateFormat
get() = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US)
/** 비동기 업로드 — fire-and-forget. 결과는 폴더 내 마커 파일로 확인. */
fun uploadAsync(context: Context, folder: File, onResult: ((Boolean, String) -> Unit)? = null) {
scope.launch {
val ok = runCatching { uploadBlocking(folder) }
.onFailure { Log.w(TAG, "upload failed: ${folder.name}", it) }
.getOrElse { e ->
writeError(folder, e)
onResult?.invoke(false, e.message ?: e.javaClass.simpleName)
return@launch
}
onResult?.invoke(true, "uploaded: ${ok.inserted}/${ok.totalRecords} (dup=${ok.duplicates})")
}
}
/** 동기 업로드 — 재시도 버튼/자동 호출 양쪽 사용. */
suspend fun uploadBlocking(folder: File): UploadResult {
val measurementFile = File(folder, "measurement.json")
if (!measurementFile.exists()) {
throw IllegalStateException("measurement.json not found in ${folder.name}")
}
val measurement = JSONObject(measurementFile.readText())
val payload = buildPayload(folder.name, measurement)
val result = LabdbClient.uploadJson(payload)
writeSuccess(folder, result)
return result
}
fun isUploaded(folder: File): Boolean = File(folder, "labdb_upload.json").exists()
fun lastError(folder: File): String? {
val f = File(folder, "labdb_upload_error.json")
return if (f.exists()) runCatching { JSONObject(f.readText()).optString("message") }.getOrNull() else null
}
/** measurement.json → labdb payload. 각 cycle = 1 record. */
private fun buildPayload(folderName: String, measurement: JSONObject): JSONObject {
val meta = measurement.optJSONObject("meta") ?: JSONObject()
val cycles = measurement.optJSONArray("cycles") ?: JSONArray()
val testId = sanitizeTestId(folderName)
val records = JSONArray()
for (i in 0 until cycles.length()) {
val cycle = cycles.optJSONObject(i) ?: continue
records.put(cycleToRecord(cycle, meta))
}
val params = JSONObject().apply {
// 측정 컨텍스트 — admin 콘솔에서 한눈에 보이도록
put("posture", meta.optString("posture"))
put("step", meta.optString("step"))
put("is_alignment", meta.optBoolean("is_alignment"))
put("examiner", meta.optString("examiner"))
meta.optString("subject_id").takeIf { it.isNotBlank() }?.let { put("subject_id", it) }
if (meta.has("true_volume_ml")) put("true_volume_ml", meta.optDouble("true_volume_ml"))
if (meta.has("abdomen_thickness_mm")) put("abdomen_thickness_mm", meta.optDouble("abdomen_thickness_mm"))
meta.optString("firmware_version").takeIf { it.isNotBlank() }?.let { put("firmware_version", it) }
meta.optString("hardware_version").takeIf { it.isNotBlank() }?.let { put("hardware_version", it) }
meta.optString("serial_number").takeIf { it.isNotBlank() }?.let { put("serial_number", it) }
put("app_version", meta.optString("app_version_name"))
put("captured_cycles", cycles.length())
}
return JSONObject().apply {
put("testId", testId)
put("dataType", DATA_TYPE)
put("sessionName", meta.optString("label").ifBlank { folderName })
put("memo", meta.optString("notes"))
meta.optLong("ended_at", 0L).takeIf { it > 0 }?.let {
put("savedAt", isoFmt.format(Date(it)))
}
put("params", params)
put("recordCount", records.length())
put("records", records)
}
}
private fun cycleToRecord(cycle: JSONObject, meta: JSONObject): JSONObject {
val rowIndex = cycle.optInt("cycle")
val tsMs = cycle.optLong("timestamp_ms")
val piezo = cycle.optJSONObject("piezo") ?: JSONObject()
val imuArr = cycle.optJSONArray("imu") ?: JSONArray()
// labdb 표준 sensor.imu는 단일 샘플. 우리는 cycle당 15 샘플이라
// 첫 샘플을 sensor.imu에 넣고 전체를 sensor.imu_samples에 free field로 보존.
val firstImu = if (imuArr.length() > 0) imuArr.optJSONObject(0) else null
val sensor = JSONObject().apply {
firstImu?.let {
put("imu", JSONObject().apply {
put("ax", it.optDouble("ax"))
put("ay", it.optDouble("ay"))
put("az", it.optDouble("az"))
put("gx", it.optDouble("gx"))
put("gy", it.optDouble("gy"))
put("gz", it.optDouble("gz"))
})
}
put("imu_samples", imuArr)
put("imu_sample_count", imuArr.length())
}
val channelsArr = JSONArray()
for (ch in 0..5) {
val data = piezo.optJSONArray("CH$ch") ?: continue
var peak = 0
var peakIdx = 0
for (i in 0 until data.length()) {
val v = data.optInt(i)
if (v > peak) { peak = v; peakIdx = i }
}
channelsArr.put(JSONObject().apply {
put("ch", ch)
put("peak", peak)
put("peakIdx", peakIdx)
put("data", data)
})
}
return JSONObject().apply {
put("rowIndex", rowIndex)
put("datetime", isoFmt.format(Date(tsMs)))
put("commandType", "MTB")
put("sensor", sensor)
put("channels", channelsArr)
}
}
/** ^[A-Za-z0-9_-]{1,40}$ 강제. 40자 초과 시 앞 31자 + "_" + 8자 SHA-1 prefix. */
private fun sanitizeTestId(raw: String): String {
// - 와 _ 이외 특수문자 제거
val cleaned = raw.replace(Regex("[^A-Za-z0-9_-]"), "_")
if (cleaned.length <= 40) return cleaned
val hash = MessageDigest.getInstance("SHA-1").digest(cleaned.toByteArray())
.joinToString("") { "%02x".format(it) }.take(8)
return cleaned.take(31) + "_" + hash
}
private fun writeSuccess(folder: File, r: UploadResult) {
val body = JSONObject().apply {
put("ok", r.ok)
put("sessionId", r.sessionId)
put("created", r.created)
put("totalRecords", r.totalRecords)
put("inserted", r.inserted)
put("duplicates", r.duplicates)
put("errors", r.errors)
put("uploadedAt", isoFmt.format(Date()))
}
File(folder, "labdb_upload.json").writeText(body.toString(2))
// 이전 실패 마커가 있었다면 정리
File(folder, "labdb_upload_error.json").delete()
}
private fun writeError(folder: File, t: Throwable) {
val body = JSONObject().apply {
put("error", t.javaClass.simpleName)
put("message", t.message ?: "")
if (t is LabdbApiError) {
put("httpStatus", t.httpStatus)
put("code", t.code)
}
put("failedAt", isoFmt.format(Date()))
}
File(folder, "labdb_upload_error.json").writeText(body.toString(2))
}
}
@@ -26,7 +26,11 @@ import com.example.medilightv2android.AppState
import com.example.medilightv2android.ble.BleManager
import com.example.medilightv2android.models.*
import com.example.medilightv2android.services.ClinicalSessionStore
import com.example.medilightv2android.services.labdb.LabdbClient
import com.example.medilightv2android.services.labdb.LabdbCredentials
import com.example.medilightv2android.services.labdb.LabdbUploader
import com.example.medilightv2android.ui.theme.*
import kotlinx.coroutines.launch
/**
* Clinical R&D 측정 화면 — "한 측정 = 한 라벨 = 한 폴더" 모델.
@@ -65,6 +69,38 @@ fun ClinicalHomeView(appState: AppState) {
var abdomenText by remember { mutableStateOf(ClinicalSessionStore.lastAbdomenThicknessMm) }
val lastFolder = ClinicalSessionStore.lastFinalizedFolder
val scope = rememberCoroutineScope()
// labdb 상태 — 화면 진입 시 + 사용자 액션 후 refresh
var labdbApiKey by remember { mutableStateOf(LabdbCredentials.apiKey) }
var labdbStatus by remember { mutableStateOf(LabdbCredentials.lastStatus ?: "unknown") }
var labdbBusy by remember { mutableStateOf(false) }
var labdbMessage by remember { mutableStateOf<String?>(null) }
// 마지막 측정 업로드 상태 — Last saved 카드용
var lastUploaded by remember(lastFolder) {
mutableStateOf(lastFolder?.let { LabdbUploader.isUploaded(it) } ?: false)
}
var lastUploadError by remember(lastFolder) {
mutableStateOf(lastFolder?.let { LabdbUploader.lastError(it) })
}
var uploadBusy by remember { mutableStateOf(false) }
// 진입 시 status 자동 확인 (apiKey 있을 때만)
LaunchedEffect(Unit) {
if (LabdbCredentials.isRegistered) {
labdbBusy = true
runCatching { LabdbClient.checkStatus() }
.onSuccess {
labdbStatus = it.status
labdbApiKey = LabdbCredentials.apiKey
}
.onFailure {
labdbMessage = it.message
labdbApiKey = LabdbCredentials.apiKey
}
labdbBusy = false
}
}
Column(
modifier = Modifier
@@ -126,23 +162,161 @@ fun ClinicalHomeView(appState: AppState) {
}
}
// 직전 측정 결과 표시
// labdb 서버 상태/등록 카드
Spacer(modifier = Modifier.height(12.dp))
val labdbBg = when {
!LabdbCredentials.isRegistered -> Color(0xFF9E9E9E).copy(alpha = 0.15f)
labdbStatus == "active" -> Color(0xFF2196F3).copy(alpha = 0.12f)
labdbStatus == "pending" -> Color(0xFFFF9800).copy(alpha = 0.15f)
else -> Color(0xFFE53935).copy(alpha = 0.12f)
}
Card(shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = labdbBg)) {
Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text("labdb.medithings.net", fontSize = 11.sp, color = MlSecondaryText)
Text(
when {
!LabdbCredentials.isRegistered -> "Not registered"
labdbBusy -> "Checking…"
else -> "Status: $labdbStatus"
},
fontSize = 14.sp, fontWeight = FontWeight.Bold
)
LabdbCredentials.deviceId?.let {
Text("deviceId: $it", fontSize = 10.sp, color = MlSecondaryText)
}
}
if (!LabdbCredentials.isRegistered) {
Button(
onClick = {
if (labdbBusy) return@Button
labdbBusy = true
labdbMessage = null
scope.launch {
runCatching {
LabdbClient.register(
context,
appVersion = try {
context.packageManager
.getPackageInfo(context.packageName, 0)
.versionName ?: "unknown"
} catch (_: Exception) { "unknown" }
)
}.onSuccess {
labdbApiKey = it.apiKey
labdbStatus = it.status
labdbMessage = it.message
Toast.makeText(context,
"Registered. Awaiting approval.",
Toast.LENGTH_LONG).show()
}.onFailure {
labdbMessage = it.message
}
labdbBusy = false
}
},
enabled = !labdbBusy,
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) { Text("Register", color = Color.White, fontSize = 12.sp) }
} else {
OutlinedButton(
onClick = {
if (labdbBusy) return@OutlinedButton
labdbBusy = true
labdbMessage = null
scope.launch {
runCatching { LabdbClient.checkStatus() }
.onSuccess {
labdbStatus = it.status
labdbApiKey = LabdbCredentials.apiKey
}
.onFailure {
labdbMessage = it.message
labdbApiKey = LabdbCredentials.apiKey
}
labdbBusy = false
}
},
enabled = !labdbBusy
) { Text("Refresh", fontSize = 12.sp) }
}
}
labdbMessage?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(it, fontSize = 11.sp, color = Color(0xFFE53935))
}
if (LabdbCredentials.isRegistered && labdbStatus == "pending") {
Spacer(modifier = Modifier.height(4.dp))
Text("admin@medithings.net 승인 후 자동 업로드됩니다.",
fontSize = 11.sp, color = MlSecondaryText)
}
}
}
// 직전 측정 결과 + 업로드 상태/재시도
if (lastFolder != null) {
Spacer(modifier = Modifier.height(12.dp))
val savedBg = when {
lastUploaded -> Color(0xFF4CAF50).copy(alpha = 0.10f)
lastUploadError != null -> Color(0xFFE53935).copy(alpha = 0.10f)
else -> Color(0xFF4CAF50).copy(alpha = 0.06f)
}
Card(
shape = RoundedCornerShape(10.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF4CAF50).copy(alpha = 0.08f))
colors = CardDefaults.cardColors(containerColor = savedBg)
) {
Row(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.CheckCircle, null,
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Column {
Text("Last saved", fontSize = 11.sp, color = MlSecondaryText)
Text(lastFolder.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Column(modifier = Modifier.padding(12.dp).fillMaxWidth()) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.CheckCircle, null,
tint = Color(0xFF4CAF50), modifier = Modifier.size(20.dp))
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text("Last saved", fontSize = 11.sp, color = MlSecondaryText)
Text(lastFolder.name, fontSize = 12.sp, fontWeight = FontWeight.SemiBold)
Text(
when {
lastUploaded -> "✓ labdb 업로드 완료"
lastUploadError != null -> "✗ 업로드 실패: $lastUploadError"
uploadBusy -> "업로드 중…"
else -> "로컬 저장만 (서버 미업로드)"
},
fontSize = 11.sp,
color = when {
lastUploaded -> Color(0xFF4CAF50)
lastUploadError != null -> Color(0xFFE53935)
else -> MlSecondaryText
}
)
}
if (!lastUploaded && LabdbCredentials.isRegistered) {
Button(
onClick = {
if (uploadBusy) return@Button
uploadBusy = true
scope.launch {
runCatching { LabdbUploader.uploadBlocking(lastFolder) }
.onSuccess {
lastUploaded = true
lastUploadError = null
Toast.makeText(context,
"Uploaded: ${it.inserted}/${it.totalRecords}",
Toast.LENGTH_SHORT).show()
}
.onFailure {
lastUploadError = it.message ?: it.javaClass.simpleName
// status도 갱신 (401/403/404 케이스 반영)
labdbApiKey = LabdbCredentials.apiKey
labdbStatus = LabdbCredentials.lastStatus ?: "unknown"
}
uploadBusy = false
}
},
enabled = !uploadBusy,
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary)
) { Text("Upload", color = Color.White, fontSize = 12.sp) }
}
}
}
}
+1536
View File
File diff suppressed because it is too large Load Diff