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,256 +36,245 @@ 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(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp)
) { ) {
Column( // ============================================
modifier = Modifier // 1. 상단 메타 바: 사이트 뱃지 + 게시판 + 특수 뱃지 + 액션
.fillMaxWidth() // ============================================
.padding(16.dp) Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) { ) {
// 상단: 사이트 뱃지 + 게시판 + 액션 // 사이트 캡슐 뱃지
Row( Surface(
modifier = Modifier.fillMaxWidth(), shape = CornerRadius.shapeSmall,
verticalAlignment = Alignment.CenterVertically color = siteColor.copy(alpha = 0.12f),
modifier = Modifier.height(26.dp)
) { ) {
// 사이트 뱃지 - 개선된 디자인 Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.padding(horizontal = 8.dp)
) {
Box(
modifier = Modifier
.size(6.dp)
.background(siteColor, CircleShape)
)
Text(
text = deal.siteType?.displayName ?: deal.siteName,
style = SpotlightTypography.badge,
color = siteColor
)
}
}
Spacer(modifier = Modifier.width(6.dp))
// 게시판 라벨
Text(
text = deal.boardDisplayName,
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( Surface(
shape = RoundedCornerShape(12.dp), shape = CornerRadius.shapeMicro,
color = siteColor.copy(alpha = 0.12f), color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f),
modifier = Modifier.height(28.dp) 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 = CornerRadius.shapeSmall,
color = KeywordGold,
modifier = Modifier.height(24.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 10.dp) modifier = Modifier.padding(horizontal = 7.dp)
) { ) {
Box( Icon(
modifier = Modifier imageVector = Icons.Filled.Star,
.size(8.dp) contentDescription = null,
.background(siteColor, CircleShape) tint = Color.White,
modifier = Modifier.size(11.dp)
) )
Text( Text(
text = deal.siteType?.displayName ?: deal.siteName, text = "내 키워드",
style = MaterialTheme.typography.labelMedium.copy( style = SpotlightTypography.badge.copy(fontSize = 11.sp),
fontWeight = FontWeight.SemiBold, color = Color.White
fontSize = 13.sp
),
color = siteColor
) )
} }
} }
Spacer(modifier = Modifier.width(4.dp))
}
Spacer(modifier = Modifier.width(8.dp)) // 인기 핫딜 뱃지 (One UI 9 Flame Orange)
if (deal.isPopular) {
// 게시판 이름 - 더 세련된 스타일
Surface( Surface(
shape = RoundedCornerShape(10.dp), shape = CornerRadius.shapeSmall,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), color = HotDealFlameColor,
modifier = Modifier.height(24.dp) modifier = Modifier.height(24.dp)
) { ) {
Text( Row(
text = deal.boardDisplayName, verticalAlignment = Alignment.CenterVertically,
style = MaterialTheme.typography.labelSmall.copy( horizontalArrangement = Arrangement.spacedBy(3.dp),
fontSize = 11.sp, modifier = Modifier.padding(horizontal = 7.dp)
fontWeight = FontWeight.Medium
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp)
)
}
Spacer(modifier = Modifier.weight(1f))
// 키워드 매칭 배지 - 더 눈에 띄는 디자인
if (deal.isKeywordMatch) {
Surface(
shape = RoundedCornerShape(10.dp),
color = Color(0xFFE53935),
modifier = Modifier.height(24.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp)
) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(12.dp)
)
Text(
text = "내 키워드",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
),
color = Color.White
)
}
}
Spacer(modifier = Modifier.width(4.dp))
}
// 인기/핫 배지 - 인기 게시물
if (deal.isPopular) {
Surface(
shape = RoundedCornerShape(10.dp),
color = Color(0xFFFF6B35), // 오렌지색
modifier = Modifier.height(24.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp)
) {
Icon(
imageVector = Icons.Filled.Whatshot,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(12.dp)
)
Text(
text = "인기",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
),
color = Color.White
)
}
}
}
// 액션 버튼들 - 더 작고 세련된 스타일
Row(
horizontalArrangement = Arrangement.spacedBy(0.dp)
) {
// 공유 버튼
IconButton(
onClick = { ShareHelper.shareDeal(context, deal) },
modifier = Modifier.size(36.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.Share, imageVector = Icons.Filled.Whatshot,
contentDescription = "공유", contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), tint = Color.White,
modifier = Modifier.size(18.dp) modifier = Modifier.size(11.dp)
) )
} Text(
text = "HOT",
// 즐겨찾기 버튼 style = SpotlightTypography.badge.copy(fontSize = 11.sp),
IconButton( color = Color.White
onClick = { onFavoriteToggle(deal.id) },
modifier = Modifier
.size(36.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),
modifier = Modifier.size(18.dp)
) )
} }
} }
} }
}
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(10.dp))
// 제목 - 더 크고 읽기 쉬운 스타일 // ============================================
Text( // 2. 제목 (Clean Spotlight Typography)
text = deal.title, // ============================================
style = if (deal.isKeywordMatch) { Text(
MaterialTheme.typography.bodyLarge.copy( text = parsedInfo.cleanTitle,
fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleMedium.copy(
fontSize = 16.sp, fontWeight = if (deal.isKeywordMatch) FontWeight.Bold else FontWeight.SemiBold,
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 lineHeight = 22.sp
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(12.dp))
// ============================================
// 3. 하단 바: 가격(Text Spotlight) + 시간 + 액션 버튼
// ============================================
Row(
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.height(10.dp)) Spacer(modifier = Modifier.weight(1f))
// 하단: 시간 + 화살표 // 액션 아이콘들 (공유 & 즐겨찾기)
Row( Row(
modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 시간 - 아이콘 없이 깔끔하게 IconButton(
Text( onClick = { ShareHelper.shareDeal(context, deal) },
text = formatTime(deal.createdAt), modifier = Modifier.size(32.dp)
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( Icon(
contentAlignment = Alignment.Center imageVector = Icons.Outlined.Share,
) { contentDescription = "공유",
Icon( tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
imageVector = Icons.Outlined.ArrowForward, modifier = Modifier.size(17.dp)
contentDescription = "이동", )
tint = MaterialTheme.colorScheme.primary, }
modifier = Modifier.size(14.dp)
) IconButton(
} onClick = { onFavoriteToggle(deal.id) },
modifier = Modifier
.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) FavoriteColor else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
modifier = Modifier.size(18.dp)
)
} }
} }
} }
@@ -295,7 +282,48 @@ fun DealItem(
} }
} }
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 now = System.currentTimeMillis()
val diff = now - timestamp val diff = now - timestamp