v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign

This commit is contained in:
2026-08-20 23:28:56 +00:00
parent 51c2ca9d72
commit b070d317c5
@@ -3,10 +3,8 @@ package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
@@ -16,20 +14,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.hotdeal.alarm.domain.model.HotDeal
import com.hotdeal.alarm.ui.theme.getSiteColor
import com.hotdeal.alarm.ui.theme.*
import com.hotdeal.alarm.util.ShareHelper
@OptIn(ExperimentalMaterial3Api::class)
/**
* One UI 9 & Material 3 Expressive Deal Item Card
* Text Spotlight 가독성과 Squircle 앰비언트 깊이감을 적용한 프리미엄 핫딜 카드
*/
@Composable
fun DealItem(
deal: HotDeal,
@@ -38,124 +36,131 @@ fun DealItem(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
// 부드러운 바운스 애니메이션
val favoriteScale by animateFloatAsState(
targetValue = if (deal.isFavorite) 1.15f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "favorite_scale"
)
val siteColor = getSiteColor(deal.siteType)
// 키워드 매칭 배경 - 옅은 붉은색 단색 배경
val cardColors = if (deal.isKeywordMatch) {
CardDefaults.elevatedCardColors(
containerColor = Color(0xFFFCE8E8) // 옅은 붉은색
)
} else {
CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
// 즐겨찾기 하트 펄스 애니메이션
val favoriteScale by animateFloatAsState(
targetValue = if (deal.isFavorite) 1.25f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "favorite_pulse"
)
// 제목에서 가격 정보 및 쇼핑몰 태그 추출 (Text Spotlight용)
val parsedInfo = remember(deal.title) {
parseDealTitle(deal.title)
}
Box(modifier = modifier) {
// 키워드 매칭 게시물도 일반 카드와 동일한 형태로 표시 (색상만 다름)
// 카드 테두리 및 배경 톤 설정 (One UI 9 Squircle Depth)
val (cardBorderColor, cardBorderWidth) = when {
deal.isKeywordMatch -> KeywordGold to 1.5.dp
deal.isPopular -> HotDealFlameColor.copy(alpha = 0.8f) to 1.2.dp
else -> MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) to 0.8.dp
}
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = cardColors,
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = 2.dp
),
onClick = onClick
val cardBgColor = when {
deal.isKeywordMatch -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
else -> MaterialTheme.colorScheme.surface
}
SquircleCard(
shape = CornerRadius.shapeSquircle,
containerColor = cardBgColor,
borderColor = cardBorderColor,
borderWidth = cardBorderWidth,
modifier = modifier
.fillMaxWidth()
.elasticPressClickable(onClick = onClick)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.padding(horizontal = 16.dp, vertical = 14.dp)
) {
// 상단: 사이트 뱃지 + 게시판 + 액션
// ============================================
// 1. 상단 메타 바: 사이트 뱃지 + 게시판 + 특수 뱃지 + 액션
// ============================================
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 사이트 뱃지 - 개선된 디자인
// 사이트 캡슐 뱃지
Surface(
shape = RoundedCornerShape(12.dp),
shape = CornerRadius.shapeSmall,
color = siteColor.copy(alpha = 0.12f),
modifier = Modifier.height(28.dp)
modifier = Modifier.height(26.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 10.dp)
horizontalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.padding(horizontal = 8.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.size(6.dp)
.background(siteColor, CircleShape)
)
Text(
text = deal.siteType?.displayName ?: deal.siteName,
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
),
style = SpotlightTypography.badge,
color = siteColor
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(6.dp))
// 게시판 이름 - 더 세련된 스타일
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.height(24.dp)
) {
// 게시판 라벨
Text(
text = deal.boardDisplayName,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.sp,
fontWeight = FontWeight.Medium
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp)
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// 쇼핑몰 태그 (추출된 경우)
if (parsedInfo.storeTag.isNotEmpty()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
shape = CornerRadius.shapeMicro,
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f),
modifier = Modifier.height(20.dp)
) {
Text(
text = parsedInfo.storeTag,
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.5.sp),
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
Spacer(modifier = Modifier.weight(1f))
// 키워드 매칭 - 더 눈에 띄는 디자인
// 키워드 매칭 (One UI 9 Amber Gold)
if (deal.isKeywordMatch) {
Surface(
shape = RoundedCornerShape(10.dp),
color = Color(0xFFE53935),
shape = CornerRadius.shapeSmall,
color = KeywordGold,
modifier = Modifier.height(24.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp)
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 7.dp)
) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(12.dp)
modifier = Modifier.size(11.dp)
)
Text(
text = "내 키워드",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
),
style = SpotlightTypography.badge.copy(fontSize = 11.sp),
color = Color.White
)
}
@@ -163,139 +168,162 @@ fun DealItem(
Spacer(modifier = Modifier.width(4.dp))
}
// 인기/핫 배지 - 인기 게시물
// 인기 핫딜 뱃지 (One UI 9 Flame Orange)
if (deal.isPopular) {
Surface(
shape = RoundedCornerShape(10.dp),
color = Color(0xFFFF6B35), // 오렌지색
shape = CornerRadius.shapeSmall,
color = HotDealFlameColor,
modifier = Modifier.height(24.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp)
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 7.dp)
) {
Icon(
imageVector = Icons.Filled.Whatshot,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(12.dp)
modifier = Modifier.size(11.dp)
)
Text(
text = "인기",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
),
text = "HOT",
style = SpotlightTypography.badge.copy(fontSize = 11.sp),
color = Color.White
)
}
}
}
}
// 액션 버튼들 - 더 작고 세련된 스타일
Spacer(modifier = Modifier.height(10.dp))
// ============================================
// 2. 제목 (Clean Spotlight Typography)
// ============================================
Text(
text = parsedInfo.cleanTitle,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = if (deal.isKeywordMatch) FontWeight.Bold else FontWeight.SemiBold,
lineHeight = 22.sp
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(12.dp))
// ============================================
// 3. 하단 바: 가격(Text Spotlight) + 시간 + 액션 버튼
// ============================================
Row(
horizontalArrangement = Arrangement.spacedBy(0.dp)
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 가격이 추출되었을 경우 볼드 강조, 없을 경우 기본 시간 강조
if (parsedInfo.priceText.isNotEmpty()) {
Text(
text = parsedInfo.priceText,
style = SpotlightTypography.priceLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.width(8.dp))
}
// 작성 상대 시간
Text(
text = formatRelativeTime(deal.createdAt),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.weight(1f))
// 액션 아이콘들 (공유 & 즐겨찾기)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 공유 버튼
IconButton(
onClick = { ShareHelper.shareDeal(context, deal) },
modifier = Modifier.size(36.dp)
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = "공유",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp)
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
modifier = Modifier.size(17.dp)
)
}
// 즐겨찾기 버튼
IconButton(
onClick = { onFavoriteToggle(deal.id) },
modifier = Modifier
.size(36.dp)
.size(32.dp)
.scale(favoriteScale)
) {
Icon(
imageVector = if (deal.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (deal.isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가",
tint = if (deal.isFavorite)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
tint = if (deal.isFavorite) FavoriteColor else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
modifier = Modifier.size(18.dp)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// 제목 - 더 크고 읽기 쉬운 스타일
Text(
text = deal.title,
style = if (deal.isKeywordMatch) {
MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
letterSpacing = (-0.1).sp
)
} else {
MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Normal,
fontSize = 15.sp
)
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(10.dp))
// 하단: 시간 + 화살표
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 시간 - 아이콘 없이 깔끔하게
Text(
text = formatTime(deal.createdAt),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Medium,
fontSize = 12.sp
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.weight(1f))
// 화살표 (클릭 유도) - 더 세련된 스타일
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
modifier = Modifier.size(28.dp)
) {
Box(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.ArrowForward,
contentDescription = "이동",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp)
)
}
}
}
}
}
}
}
private fun formatTime(timestamp: Long): String {
/**
* 핫딜 제목에서 상점 태그 및 가격을 분리하는 헬퍼
*/
private data class ParsedDealInfo(
val storeTag: String,
val priceText: String,
val cleanTitle: String
)
private fun parseDealTitle(rawTitle: String): ParsedDealInfo {
var title = rawTitle.trim()
var store = ""
var price = ""
// 상점 태그 추출: [스토어]
if (title.startsWith("[")) {
val closeIdx = title.indexOf("]")
if (closeIdx in 1..25) {
store = title.substring(1, closeIdx).trim()
title = title.substring(closeIdx + 1).trim()
}
}
// 끝부분 가격 추출: (10,000원), (35,000/무료) 등
val pricePattern = Regex("""\(([^()]*?(?:원|₩|\$|무료|배송|KRW)[^()]*?)\)$""")
val match = pricePattern.find(title)
if (match != null) {
price = match.groupValues[1].trim()
title = title.substring(0, match.range.first).trim()
}
return ParsedDealInfo(
storeTag = store,
priceText = price,
cleanTitle = if (title.isEmpty()) rawTitle else title
)
}
/**
* 상대 시간 포맷팅
*/
private fun formatRelativeTime(timestamp: Long): String {
val now = System.currentTimeMillis()
val diff = now - timestamp