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.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.outlined.*
@@ -16,20 +14,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale 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.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.hotdeal.alarm.domain.model.HotDeal 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 import com.hotdeal.alarm.util.ShareHelper
@OptIn(ExperimentalMaterial3Api::class) /**
* One UI 9 & Material 3 Expressive Deal Item Card
* Text Spotlight 가독성과 Squircle 앰비언트 깊이감을 적용한 프리미엄 핫딜 카드
*/
@Composable @Composable
fun DealItem( fun DealItem(
deal: HotDeal, deal: HotDeal,
@@ -38,124 +36,131 @@ fun DealItem(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current 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 siteColor = getSiteColor(deal.siteType)
// 키워드 매칭 배경 - 옅은 붉은색 단색 배경 // 즐겨찾기 하트 펄스 애니메이션
val cardColors = if (deal.isKeywordMatch) { val favoriteScale by animateFloatAsState(
CardDefaults.elevatedCardColors( targetValue = if (deal.isFavorite) 1.25f else 1f,
containerColor = Color(0xFFFCE8E8) // 옅은 붉은색 animationSpec = spring(
) dampingRatio = Spring.DampingRatioMediumBouncy,
} else { stiffness = Spring.StiffnessMedium
CardDefaults.elevatedCardColors( ),
containerColor = MaterialTheme.colorScheme.surface 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( val cardBgColor = when {
modifier = Modifier.fillMaxWidth(), deal.isKeywordMatch -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
shape = RoundedCornerShape(20.dp), else -> MaterialTheme.colorScheme.surface
colors = cardColors, }
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = 2.dp SquircleCard(
), shape = CornerRadius.shapeSquircle,
onClick = onClick containerColor = cardBgColor,
borderColor = cardBorderColor,
borderWidth = cardBorderWidth,
modifier = modifier
.fillMaxWidth()
.elasticPressClickable(onClick = onClick)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(horizontal = 16.dp, vertical = 14.dp)
) { ) {
// 상단: 사이트 뱃지 + 게시판 + 액션 // ============================================
// 1. 상단 메타 바: 사이트 뱃지 + 게시판 + 특수 뱃지 + 액션
// ============================================
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 사이트 뱃지 - 개선된 디자인 // 사이트 캡슐 뱃지
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = CornerRadius.shapeSmall,
color = siteColor.copy(alpha = 0.12f), color = siteColor.copy(alpha = 0.12f),
modifier = Modifier.height(28.dp) modifier = Modifier.height(26.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.padding(horizontal = 10.dp) modifier = Modifier.padding(horizontal = 8.dp)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(8.dp) .size(6.dp)
.background(siteColor, CircleShape) .background(siteColor, CircleShape)
) )
Text( Text(
text = deal.siteType?.displayName ?: deal.siteName, text = deal.siteType?.displayName ?: deal.siteName,
style = MaterialTheme.typography.labelMedium.copy( style = SpotlightTypography.badge,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
),
color = siteColor 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(
text = deal.boardDisplayName, text = deal.boardDisplayName,
style = MaterialTheme.typography.labelSmall.copy( style = MaterialTheme.typography.labelSmall,
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
fontWeight = FontWeight.Medium maxLines = 1,
), overflow = TextOverflow.Ellipsis
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp)
) )
// 쇼핑몰 태그 (추출된 경우)
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)) Spacer(modifier = Modifier.weight(1f))
// 키워드 매칭 - 더 눈에 띄는 디자인 // 키워드 매칭 (One UI 9 Amber Gold)
if (deal.isKeywordMatch) { if (deal.isKeywordMatch) {
Surface( Surface(
shape = RoundedCornerShape(10.dp), shape = CornerRadius.shapeSmall,
color = Color(0xFFE53935), color = KeywordGold,
modifier = Modifier.height(24.dp) modifier = Modifier.height(24.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 10.dp) modifier = Modifier.padding(horizontal = 7.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Star, imageVector = Icons.Filled.Star,
contentDescription = null, contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(12.dp) modifier = Modifier.size(11.dp)
) )
Text( Text(
text = "내 키워드", text = "내 키워드",
style = MaterialTheme.typography.labelSmall.copy( style = SpotlightTypography.badge.copy(fontSize = 11.sp),
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
),
color = Color.White color = Color.White
) )
} }
@@ -163,139 +168,162 @@ fun DealItem(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
// 인기/핫 배지 - 인기 게시물 // 인기 핫딜 뱃지 (One UI 9 Flame Orange)
if (deal.isPopular) { if (deal.isPopular) {
Surface( Surface(
shape = RoundedCornerShape(10.dp), shape = CornerRadius.shapeSmall,
color = Color(0xFFFF6B35), // 오렌지색 color = HotDealFlameColor,
modifier = Modifier.height(24.dp) modifier = Modifier.height(24.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 10.dp) modifier = Modifier.padding(horizontal = 7.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Whatshot, imageVector = Icons.Filled.Whatshot,
contentDescription = null, contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(12.dp) modifier = Modifier.size(11.dp)
) )
Text( Text(
text = "인기", text = "HOT",
style = MaterialTheme.typography.labelSmall.copy( style = SpotlightTypography.badge.copy(fontSize = 11.sp),
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
),
color = Color.White 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( 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( IconButton(
onClick = { ShareHelper.shareDeal(context, deal) }, onClick = { ShareHelper.shareDeal(context, deal) },
modifier = Modifier.size(36.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.Share, imageVector = Icons.Outlined.Share,
contentDescription = "공유", contentDescription = "공유",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
modifier = Modifier.size(18.dp) modifier = Modifier.size(17.dp)
) )
} }
// 즐겨찾기 버튼
IconButton( IconButton(
onClick = { onFavoriteToggle(deal.id) }, onClick = { onFavoriteToggle(deal.id) },
modifier = Modifier modifier = Modifier
.size(36.dp) .size(32.dp)
.scale(favoriteScale) .scale(favoriteScale)
) { ) {
Icon( Icon(
imageVector = if (deal.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, imageVector = if (deal.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (deal.isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가", contentDescription = if (deal.isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가",
tint = if (deal.isFavorite) tint = if (deal.isFavorite) FavoriteColor else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
} }
} }
} }
}
}
}
Spacer(modifier = Modifier.height(12.dp)) /**
* 핫딜 제목에서 상점 태그 및 가격을 분리하는 헬퍼
// 제목 - 더 크고 읽기 쉬운 스타일 */
Text( private data class ParsedDealInfo(
text = deal.title, val storeTag: String,
style = if (deal.isKeywordMatch) { val priceText: String,
MaterialTheme.typography.bodyLarge.copy( val cleanTitle: String
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)) private fun parseDealTitle(rawTitle: String): ParsedDealInfo {
var title = rawTitle.trim()
var store = ""
var price = ""
// 하단: 시간 + 화살표 // 상점 태그 추출: [스토어]
Row( if (title.startsWith("[")) {
modifier = Modifier.fillMaxWidth(), val closeIdx = title.indexOf("]")
verticalAlignment = Alignment.CenterVertically if (closeIdx in 1..25) {
) { store = title.substring(1, closeIdx).trim()
// 시간 - 아이콘 없이 깔끔하게 title = title.substring(closeIdx + 1).trim()
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)) // 끝부분 가격 추출: (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(
Surface( storeTag = store,
shape = CircleShape, priceText = price,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), cleanTitle = if (title.isEmpty()) rawTitle else title
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 fun formatRelativeTime(timestamp: Long): String {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val diff = now - timestamp val diff = now - timestamp