From 0fe0687041e609a7107a1621bbaeb1b726ca8795 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Wed, 12 Aug 2026 11:30:36 +0900 Subject: [PATCH] =?UTF-8?q?ui(dfu):=20=ED=8E=8C=EC=9B=A8=EC=96=B4=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=95=B1=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?=C2=B7=20=ED=95=9C=EA=B8=80=ED=99=94=20=C2=B7=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EC=B9=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미니멈 리디자인 (기존 3카드 구조 유지): · 배경 = MlBackground · 카드 = MlCardBackground · RoundedCornerShape 16 · 헤드라인 "MEDiDFU" 제거 (앱 브랜드와 무관) · 카드 label 한글화: "Firmware (bundled)" → "펌웨어 정보" · "Devices (n)" → "발견된 기기 (n)" · "Log" → "상세 로그" · Phase 사용자 한글 라벨 + 색상 dot 상태칩: IDLE→"대기 중" (회색) · SCANNING→"기기 검색 중…" (주황) · CONNECTED→"연결됨 · 업데이트 준비 완료" (녹색) · UPLOADING→"업데이트 진행 중…" (파랑) · COMPLETED→"완료 ✓" (녹색) · FAILED→"실패" (빨강) · ENTERING_DFU/SEARCHING_DFU 포함 · 주 액션 "업데이트 시작" = MlTeal→MlPrimary gradient primary 버튼 (앱 다른 primary 버튼 · Onboarding "Get started" 와 동일 스타일) · 보조 액션 (검색/중지/연결 해제/취소/초기화) = outlined + MlPrimary 톤 · 취소는 MlCritical 톤 · 진행률: 8dp 굵은 LinearProgressIndicator · MlPrimary 색 · Row 로 좌 (퍼센트 SemiBold) / 우 (KB / KB 표기) · DeviceRow: 이름 SemiBold · address 제거 (RSSI 만) · "DFU 모드" 칩 MlTeal · "Connect" → "연결" · outlined 스타일 · 권한 안내: 문구 MlSecondaryText · 버튼도 gradient primary · 로그: 카드 배경 MlSecondaryText 8% alpha (연한 회색) · 텍스트 MlSecondaryText Import 추가: MlPrimary/MlTeal/MlBackground/MlCardBackground/MlSecondaryText/ MlCritical/MlSuccess/MlWarning · ButtonDefaults · Brush · PaddingValues. 빌드: BUILD SUCCESSFUL. (폰 미연결 · install 은 재연결 후 별도.) --- .../firmware/ui/FirmwareUpdateView.kt | 311 +++++++++++++----- 1 file changed, 225 insertions(+), 86 deletions(-) diff --git a/app/src/main/java/com/medithings/vesiscan/firmware/ui/FirmwareUpdateView.kt b/app/src/main/java/com/medithings/vesiscan/firmware/ui/FirmwareUpdateView.kt index b486fc9..4e189d9 100644 --- a/app/src/main/java/com/medithings/vesiscan/firmware/ui/FirmwareUpdateView.kt +++ b/app/src/main/java/com/medithings/vesiscan/firmware/ui/FirmwareUpdateView.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -25,6 +26,16 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.material3.ButtonDefaults +import androidx.compose.ui.graphics.Brush +import com.medithings.vesiscan.ui.theme.MlBackground +import com.medithings.vesiscan.ui.theme.MlCardBackground +import com.medithings.vesiscan.ui.theme.MlCritical +import com.medithings.vesiscan.ui.theme.MlPrimary +import com.medithings.vesiscan.ui.theme.MlSecondaryText +import com.medithings.vesiscan.ui.theme.MlSuccess +import com.medithings.vesiscan.ui.theme.MlTeal +import com.medithings.vesiscan.ui.theme.MlWarning import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed @@ -113,13 +124,13 @@ fun FirmwareUpdateScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { } DisposableEffect(Unit) { onDispose { manager.shutdown() } } - Column(modifier = modifier.fillMaxSize()) { + Column(modifier = modifier.fillMaxSize().background(MlBackground)) { Row( modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 4.dp), verticalAlignment = Alignment.CenterVertically, ) { IconButton(onClick = onBack) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로") + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "뒤로", tint = MlPrimary) } Text("펌웨어 업데이트", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold) @@ -135,13 +146,64 @@ fun FirmwareUpdateScreen(onBack: () -> Unit, modifier: Modifier = Modifier) { Text( "펌웨어 업데이트에는 Bluetooth 권한이 필요합니다.", style = MaterialTheme.typography.bodyMedium, + color = MlSecondaryText, ) - Button(onClick = { launcher.launch(perms) }) { Text("권한 허용") } + PrimaryGradientButton(text = "권한 허용", onClick = { launcher.launch(perms) }) } } } } +/** 앱 스타일 통일 primary 버튼 (MlTeal → MlPrimary gradient · rounded 14). */ +@Composable +private fun PrimaryGradientButton( + text: String, + onClick: () -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + Button( + onClick = onClick, + enabled = enabled, + modifier = modifier.heightIn(min = 48.dp), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + ), + contentPadding = PaddingValues(), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .background( + brush = if (enabled) + Brush.horizontalGradient(listOf(MlTeal, MlPrimary)) + else Brush.horizontalGradient(listOf(Color(0xFFCCCCCC), Color(0xFFAAAAAA))), + shape = RoundedCornerShape(14.dp), + ) + .padding(horizontal = 20.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, + ) { + Text(text, color = Color.White, fontWeight = FontWeight.SemiBold, fontSize = 15.sp) + } + } +} + +/** Phase → 사용자용 한글 라벨 + 상태 색상. */ +private fun phaseKo(phase: DfuPhase): Pair = when (phase) { + DfuPhase.IDLE -> "대기 중" to MlSecondaryText + DfuPhase.SCANNING -> "기기 검색 중…" to MlWarning + DfuPhase.CONNECTING -> "연결 중…" to MlWarning + DfuPhase.CONNECTED -> "연결됨 · 업데이트 준비 완료" to MlSuccess + DfuPhase.ENTERING_DFU -> "DFU 모드 진입 중…" to MlPrimary + DfuPhase.SEARCHING_DFU -> "DFU 모드 확인 중…" to MlPrimary + DfuPhase.UPLOADING -> "업데이트 진행 중…" to MlPrimary + DfuPhase.COMPLETED -> "완료 ✓" to MlSuccess + DfuPhase.FAILED -> "실패" to MlCritical +} + @Composable fun DfuScreen(manager: DfuManager, modifier: Modifier = Modifier) { val phase by manager.phase @@ -152,133 +214,200 @@ fun DfuScreen(manager: DfuManager, modifier: Modifier = Modifier) { val bytesSent by manager.bytesSent val target by manager.target + val cardShape = RoundedCornerShape(16.dp) + val cardColors = CardDefaults.cardColors(containerColor = MlCardBackground) + Column( modifier = modifier .fillMaxSize() + .background(MlBackground) .padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Text("MEDiDFU", style = MaterialTheme.typography.headlineSmall) - - // ── Anything outside the app that would silently break scanning ─────────────────── + // ── 스캔 실패 원인 안내 (블루투스 꺼짐 등) ──────────────────────────────────────── val blocker = manager.bleBlockingCondition() if (blocker != null) { Card( modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - ), + shape = cardShape, + colors = CardDefaults.cardColors(containerColor = MlCritical.copy(alpha = 0.10f)), ) { Text( blocker, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(14.dp), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer, + color = MlCritical, + fontWeight = FontWeight.Medium, ) } } - // ── Firmware bundled in the APK ──────────────────────────────────────────────────── - Card(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp)) { - Text("Firmware (bundled)", style = MaterialTheme.typography.labelLarge) - Spacer(Modifier.height(4.dp)) + // ── 펌웨어 정보 (APK 번들) ──────────────────────────────────────────────────────── + Card(modifier = Modifier.fillMaxWidth(), shape = cardShape, colors = cardColors) { + Column(Modifier.padding(14.dp)) { + Text("펌웨어 정보", style = MaterialTheme.typography.labelLarge, color = MlSecondaryText) + Spacer(Modifier.height(6.dp)) val fw = firmware when { fw != null -> { - Text(fw.assetName, style = MaterialTheme.typography.bodyMedium) - Text( - "${fw.imageFileName} · v${fw.version.ifEmpty { "?" }} · ${fw.sizeBytes} bytes", + val ver = if (fw.version.isNotEmpty()) "v${fw.version}" else "버전 정보 없음" + val sizeKb = fw.sizeBytes / 1024 + Text("$ver · ${sizeKb} KB", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold) + Spacer(Modifier.height(2.dp)) + Text(fw.imageFileName, style = MaterialTheme.typography.bodySmall, - ) + color = MlSecondaryText) } firmwareError != null -> Text( firmwareError ?: "", style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + color = MlCritical, ) - else -> Text("Loading…", style = MaterialTheme.typography.bodySmall) + else -> Text("불러오는 중…", + style = MaterialTheme.typography.bodySmall, + color = MlSecondaryText) } } } - // ── Status + progress ───────────────────────────────────────────────────────────── - Card(modifier = Modifier.fillMaxWidth()) { - Column(Modifier.padding(12.dp)) { - Text(phase.name, style = MaterialTheme.typography.labelLarge) - Spacer(Modifier.height(4.dp)) - Text(status, style = MaterialTheme.typography.bodyMedium) - target?.let { - Text( - "${it.name} · ${it.address}", - style = MaterialTheme.typography.bodySmall, + // ── 상태 + 진행률 ───────────────────────────────────────────────────────────────── + Card(modifier = Modifier.fillMaxWidth(), shape = cardShape, colors = cardColors) { + Column(Modifier.padding(14.dp)) { + Text("기기 상태", style = MaterialTheme.typography.labelLarge, color = MlSecondaryText) + Spacer(Modifier.height(6.dp)) + val (phaseLabel, phaseColor) = phaseKo(phase) + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + modifier = Modifier + .size(10.dp) + .background(phaseColor, shape = RoundedCornerShape(50)), ) + Spacer(Modifier.width(8.dp)) + Text(phaseLabel, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = phaseColor) + } + if (status.isNotEmpty()) { + Spacer(Modifier.height(4.dp)) + Text(status, style = MaterialTheme.typography.bodySmall, color = MlSecondaryText) + } + target?.let { + Spacer(Modifier.height(4.dp)) + Text("기기: ${it.name}", + style = MaterialTheme.typography.bodySmall, + color = MlSecondaryText) } if (phase == DfuPhase.UPLOADING || phase == DfuPhase.COMPLETED) { - Spacer(Modifier.height(8.dp)) + Spacer(Modifier.height(10.dp)) LinearProgressIndicator( progress = { progress }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().height(8.dp), + color = MlPrimary, + trackColor = MlPrimary.copy(alpha = 0.15f), ) - Spacer(Modifier.height(4.dp)) + Spacer(Modifier.height(6.dp)) val total = firmware?.sizeBytes ?: 0 - Text( - "${(progress * 100).roundToInt()}% ($bytesSent / $total bytes)", - style = MaterialTheme.typography.bodySmall, - ) + val sentKb = bytesSent / 1024 + val totalKb = total / 1024 + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text("${(progress * 100).roundToInt()}%", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MlPrimary) + Text("$sentKb KB / $totalKb KB", + style = MaterialTheme.typography.bodySmall, + color = MlSecondaryText) + } } } } - // ── Actions ─────────────────────────────────────────────────────────────────────── - // 2026-08-03: auto-connect 로 대부분 target 즉시 설정됨. - // target 없는 경우 (fresh install · 새 기기) 에만 수동 Scan/Stop 노출. + // ── 액션 버튼 (primary 는 gradient · secondary 는 outline) ─────────────────────── + // 2026-08-03: auto-connect 로 대부분 target 즉시 설정됨. target 없을 때만 Scan 노출. val hasTarget = target != null - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - if (!hasTarget || phase == DfuPhase.IDLE || phase == DfuPhase.SCANNING) { - if (phase == DfuPhase.SCANNING) { - OutlinedButton(onClick = { manager.stopScan() }) { Text("Stop") } - } else { - OutlinedButton( - onClick = { manager.startScan() }, - enabled = !manager.isBusy, - ) { Text("Scan") } + val outlineColors = ButtonDefaults.outlinedButtonColors(contentColor = MlPrimary) + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // 주 액션: 업데이트 시작 (조건부 gradient primary) + if (phase != DfuPhase.UPLOADING && phase != DfuPhase.COMPLETED && phase != DfuPhase.FAILED) { + PrimaryGradientButton( + text = "업데이트 시작", + onClick = { manager.startDfu() }, + enabled = manager.canStartDfu, + modifier = Modifier.fillMaxWidth(), + ) + } + + // 보조 액션 (조건부) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + if (!hasTarget || phase == DfuPhase.IDLE || phase == DfuPhase.SCANNING) { + if (phase == DfuPhase.SCANNING) { + OutlinedButton( + onClick = { manager.stopScan() }, + shape = RoundedCornerShape(12.dp), + colors = outlineColors, + ) { Text("검색 중지") } + } else { + OutlinedButton( + onClick = { manager.startScan() }, + enabled = !manager.isBusy, + shape = RoundedCornerShape(12.dp), + colors = outlineColors, + ) { Text("기기 검색") } + } + } + if (phase == DfuPhase.CONNECTED) { + OutlinedButton( + onClick = { manager.disconnect() }, + shape = RoundedCornerShape(12.dp), + colors = outlineColors, + ) { Text("연결 해제") } + } + if (phase == DfuPhase.UPLOADING) { + OutlinedButton( + onClick = { manager.cancelDfu() }, + shape = RoundedCornerShape(12.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MlCritical), + ) { Text("취소") } + } + if (phase == DfuPhase.COMPLETED || phase == DfuPhase.FAILED) { + OutlinedButton( + onClick = { manager.reset() }, + shape = RoundedCornerShape(12.dp), + colors = outlineColors, + ) { Text("초기화") } } - } - - Button( - onClick = { manager.startDfu() }, - enabled = manager.canStartDfu, - ) { Text("Start DFU") } - - if (phase == DfuPhase.CONNECTED) { - OutlinedButton(onClick = { manager.disconnect() }) { Text("Disconnect") } - } - if (phase == DfuPhase.UPLOADING) { - OutlinedButton(onClick = { manager.cancelDfu() }) { Text("Cancel") } - } - if (phase == DfuPhase.COMPLETED || phase == DfuPhase.FAILED) { - OutlinedButton(onClick = { manager.reset() }) { Text("Reset") } } } - // ── Discovered devices — target 없을 때만 (auto-connect 실패 · 첫 사용) ──────── + // ── 발견된 기기 목록 (auto-connect 실패 · 첫 사용) ─────────────────────────────── if (!hasTarget) { Text( - "Devices (${manager.devices.size})", + "발견된 기기 (${manager.devices.size})", style = MaterialTheme.typography.labelLarge, + color = MlSecondaryText, ) - Card(modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp, max = 220.dp)) { + Card( + modifier = Modifier.fillMaxWidth().heightIn(min = 80.dp, max = 220.dp), + shape = cardShape, + colors = cardColors, + ) { if (manager.devices.isEmpty()) { Text( when (phase) { - DfuPhase.SCANNING -> "Scanning…" - else -> "No devices yet — tap Scan." + DfuPhase.SCANNING -> "검색 중…" + else -> "기기가 없습니다. 검색을 눌러주세요." }, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(14.dp), style = MaterialTheme.typography.bodySmall, + color = MlSecondaryText, ) } else { LazyColumn { @@ -288,15 +417,15 @@ fun DfuScreen(manager: DfuManager, modifier: Modifier = Modifier) { enabled = !manager.isBusy, onClick = { manager.connect(device) }, ) - HorizontalDivider() + HorizontalDivider(color = MlBackground) } } } } } - // ── Log ─────────────────────────────────────────────────────────────────────────── - Text("Log", style = MaterialTheme.typography.labelLarge) + // ── 상세 로그 (개발자용 · 그대로 유지) ──────────────────────────────────────────── + Text("상세 로그", style = MaterialTheme.typography.labelLarge, color = MlSecondaryText) LogView( lines = manager.logLines, modifier = Modifier.fillMaxWidth().weight(1f), @@ -309,33 +438,41 @@ private fun DeviceRow(device: ScannedDevice, enabled: Boolean, onClick: () -> Un Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), + .padding(horizontal = 14.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { Row(verticalAlignment = Alignment.CenterVertically) { - Text(device.name, style = MaterialTheme.typography.bodyMedium) + Text(device.name, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) if (device.inDfuMode) { Spacer(Modifier.width(6.dp)) Surface( - color = MaterialTheme.colorScheme.tertiaryContainer, - shape = RoundedCornerShape(4.dp), + color = MlTeal.copy(alpha = 0.15f), + shape = RoundedCornerShape(6.dp), ) { Text( - "DFU", - modifier = Modifier.padding(horizontal = 4.dp), + "DFU 모드", + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onTertiaryContainer, + color = MlTeal, + fontWeight = FontWeight.SemiBold, ) } } } + Spacer(Modifier.height(2.dp)) Text( - "${device.address} · ${device.rssi} dBm", + "${device.rssi} dBm", style = MaterialTheme.typography.bodySmall, + color = MlSecondaryText, ) } - Button(onClick = onClick, enabled = enabled) { Text("Connect") } + OutlinedButton( + onClick = onClick, + enabled = enabled, + shape = RoundedCornerShape(10.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MlPrimary), + ) { Text("연결", fontWeight = FontWeight.SemiBold) } } } @@ -347,14 +484,15 @@ private fun LogView(lines: List, modifier: Modifier = Modifier) { } Card( modifier = modifier, - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MlSecondaryText.copy(alpha = 0.08f)), ) { LazyColumn( state = listState, modifier = Modifier .fillMaxSize() .background(Color.Transparent) - .padding(8.dp), + .padding(10.dp), ) { itemsIndexed(lines) { _, line -> Text( @@ -362,6 +500,7 @@ private fun LogView(lines: List, modifier: Modifier = Modifier) { fontFamily = FontFamily.Monospace, fontSize = 11.sp, fontWeight = FontWeight.Normal, + color = MlSecondaryText, style = MaterialTheme.typography.bodySmall, ) }