ui(dfu): 펌웨어 업데이트 화면 앱 스타일 통일 · 한글화 · 상태칩

미니멈 리디자인 (기존 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 은 재연결 후 별도.)
This commit is contained in:
2026-08-12 11:30:36 +09:00
parent e414a41a6d
commit 0fe0687041
@@ -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<String, Color> = 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<String>, 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<String>, modifier: Modifier = Modifier) {
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
fontWeight = FontWeight.Normal,
color = MlSecondaryText,
style = MaterialTheme.typography.bodySmall,
)
}