feat: UI/UX 대폭 개선 및 버전 1.3.0 업데이트

- 아이콘 디자인 개선 (알림 벨 + 불꽃)
- 메인 화면 헤더 제거로 화면 넓게 사용
- 설정 화면 알림 설정 UI 대폭 개선
- 키워드 카드 세련된 디자인 적용
- 전체 UI/UX 모던하게 고도화
- 필터 칩 애니메이션 추가
- 버전 1.2.0 -> 1.3.0 (versionCode 4 -> 5)
This commit is contained in:
sanjeok77
2026-03-04 07:53:13 +09:00
parent 6f3d8faf25
commit 0020a2a6d4
8 changed files with 1498 additions and 521 deletions

View File

@@ -24,8 +24,8 @@ android {
applicationId = "com.hotdeal.alarm" applicationId = "com.hotdeal.alarm"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 4 versionCode = 5
versionName = "1.2.0" versionName = "1.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@@ -8,16 +8,16 @@ 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.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
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
@@ -27,6 +27,7 @@ import com.hotdeal.alarm.ui.theme.Spacing
import com.hotdeal.alarm.ui.theme.getSiteColor import com.hotdeal.alarm.ui.theme.getSiteColor
import com.hotdeal.alarm.util.ShareHelper import com.hotdeal.alarm.util.ShareHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DealItem( fun DealItem(
deal: HotDeal, deal: HotDeal,
@@ -35,183 +36,267 @@ fun DealItem(
) { ) {
var isFavorite by remember { mutableStateOf(false) } var isFavorite by remember { mutableStateOf(false) }
val context = LocalContext.current val context = LocalContext.current
val favoriteScale by animateFloatAsState( val favoriteScale by animateFloatAsState(
targetValue = if (isFavorite) 1.3f else 1f, targetValue = if (isFavorite) 1.2f else 1f,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
), ),
label = "favorite_scale" label = "favorite_scale"
) )
val siteColor = getSiteColor(deal.siteType)
// 키워드 매칭된 핫딜은 더 강한 시각적 강조 val siteColor = getSiteColor(deal.siteType)
val cardColor = if (deal.isKeywordMatch) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
} else {
MaterialTheme.colorScheme.surface
}
val borderColor = if (deal.isKeywordMatch) { // 키워드 매칭 강조
MaterialTheme.colorScheme.primary val cardColors = if (deal.isKeywordMatch) {
} else { CardDefaults.elevatedCardColors(
Color.Transparent containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
} )
} else {
CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
}
val cardModifier = if (deal.isKeywordMatch) {
modifier.fillMaxWidth()
.border(3.dp, borderColor, MaterialTheme.shapes.large)
} else {
modifier.fillMaxWidth()
}
ElevatedCard( ElevatedCard(
modifier = cardModifier, modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large, shape = RoundedCornerShape(20.dp),
colors = CardDefaults.elevatedCardColors(containerColor = cardColor), colors = cardColors,
elevation = CardDefaults.elevatedCardElevation( elevation = CardDefaults.elevatedCardElevation(
defaultElevation = if (deal.isKeywordMatch) 4.dp else 2.dp defaultElevation = if (deal.isKeywordMatch) 6.dp else 2.dp
), ),
onClick = onClick onClick = onClick
) { ) {
Column( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(Spacing.md) .then(
) { if (deal.isKeywordMatch) {
// 상단: 사이트 뱃지 + 게시판 + 액션 Modifier.background(
Row( brush = Brush.horizontalGradient(
modifier = Modifier.fillMaxWidth(), colors = listOf(
verticalAlignment = Alignment.CenterVertically MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
) { MaterialTheme.colorScheme.primary.copy(alpha = 0.05f)
// 사이트 뱃지 (색상으로 구분) )
Surface( )
shape = RoundedCornerShape(12.dp),
color = siteColor.copy(alpha = 0.15f),
modifier = Modifier.height(26.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
) {
// 색상 점
Box(
modifier = Modifier
.size(8.dp)
.background(siteColor, CircleShape)
) )
} else {
Modifier
}
)
) {
// 키워드 매칭 인디케이터 (왼쪽)
if (deal.isKeywordMatch) {
Box(
modifier = Modifier
.align(Alignment.CenterStart)
.width(4.dp)
.fillMaxHeight()
.background(MaterialTheme.colorScheme.primary)
)
}
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.then(
if (deal.isKeywordMatch) {
Modifier.padding(start = 8.dp)
} else {
Modifier
}
)
) {
// 상단: 사이트 뱃지 + 게시판 + 액션
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 사이트 뱃지 (개선된 디자인)
Surface(
shape = RoundedCornerShape(10.dp),
color = siteColor.copy(alpha = 0.12f),
modifier = Modifier.height(28.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 10.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(siteColor, CircleShape)
)
Text(
text = deal.siteType?.displayName ?: deal.siteName,
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = siteColor
)
}
}
Spacer(modifier = Modifier.width(8.dp))
// 게시판 이름
Surface(
shape = RoundedCornerShape(8.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.height(24.dp)
) {
Text( Text(
text = deal.siteType?.displayName ?: deal.siteName, text = deal.boardDisplayName,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = siteColor color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) )
} }
Spacer(modifier = Modifier.weight(1f))
// 키워드 매칭 배지
if (deal.isKeywordMatch) {
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.height(28.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp)
) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(14.dp)
)
Text(
text = "내 키워드",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
Spacer(modifier = Modifier.width(4.dp))
}
// 액션 버튼들
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
// 공유 버튼
IconButton(
onClick = { ShareHelper.shareDeal(context, deal) },
modifier = Modifier.size(36.dp)
) {
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = "공유",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
// 즐겨찾기 버튼
IconButton(
onClick = { isFavorite = !isFavorite },
modifier = Modifier
.size(36.dp)
.scale(favoriteScale)
) {
Icon(
imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가",
tint = if (isFavorite)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
)
}
}
} }
Spacer(modifier = Modifier.width(Spacing.sm)) Spacer(modifier = Modifier.height(12.dp))
// 게시판 이름 // 제목
Text( Text(
text = deal.boardDisplayName, text = deal.title,
style = MaterialTheme.typography.labelSmall, style = if (deal.isKeywordMatch) {
color = MaterialTheme.colorScheme.onSurfaceVariant MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold)
} else {
MaterialTheme.typography.bodyLarge
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.height(10.dp))
// 키워드 매칭 배지 - 별 아이콘으로 변경 // 하단: 시간 + 추가 정보
if (deal.isKeywordMatch) { Row(
Surface( modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp), verticalAlignment = Alignment.CenterVertically
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.height(26.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Icon(
imageVector = Icons.Default.Star,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(14.dp)
)
Text(
text = "내 키워드",
style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
Spacer(modifier = Modifier.width(Spacing.xs))
}
// 공유 버튼
IconButton(
onClick = { ShareHelper.shareDeal(context, deal) },
modifier = Modifier.size(32.dp)
) { ) {
// 시간
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = Icons.Outlined.Schedule,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(14.dp)
)
Text(
text = formatTime(deal.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(modifier = Modifier.weight(1f))
// 화살표 (클릭 유도)
Icon( Icon(
imageVector = Icons.Default.Share, imageVector = Icons.Outlined.ArrowForward,
contentDescription = "공유", contentDescription = "이동",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
modifier = Modifier.size(18.dp) modifier = Modifier.size(16.dp)
)
}
// 즐겨찾기 버튼
IconButton(
onClick = { isFavorite = !isFavorite },
modifier = Modifier.size(32.dp).scale(favoriteScale)
) {
Icon(
imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
contentDescription = if (isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가",
tint = if (isFavorite)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
) )
} }
} }
Spacer(modifier = Modifier.height(Spacing.sm))
// 제목 (키워드 매칭 시 굵게)
Text(
text = deal.title,
style = if (deal.isKeywordMatch) {
MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold)
} else {
MaterialTheme.typography.bodyLarge
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(Spacing.sm))
// 시간
Text(
text = formatTime(deal.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} }
} }
} }
// Brush import를 위해 추가
private fun Modifier.background(brush: Brush, shape: Shape): Modifier {
return this.then(
androidx.compose.ui.draw.drawBehind {
drawRect(brush)
}
)
}
private object Brush {
fun horizontalGradient(colors: List<Color>): androidx.compose.ui.graphics.Brush {
return androidx.compose.ui.graphics.Brush.horizontalGradient(colors)
}
}
private fun formatTime(timestamp: Long): String { private fun formatTime(timestamp: Long): String {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val diff = now - timestamp val diff = now - timestamp
return when { return when {
diff < 60_000 -> "방금 전" diff < 60_000 -> "방금 전"
diff < 3_600_000 -> "${diff / 60_000}분 전" diff < 3_600_000 -> "${diff / 60_000}분 전"

View File

@@ -1,20 +1,30 @@
package com.hotdeal.alarm.presentation.components package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* 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.Icons
import androidx.compose.material.icons.filled.Search import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.ShoppingCart import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.hotdeal.alarm.ui.theme.Spacing import com.hotdeal.alarm.ui.theme.Spacing
/** /**
* 빈 상태 컴포넌트 * 빈 상태 컴포넌트 - 개선된 디자인
*/ */
@Composable @Composable
fun EmptyState( fun EmptyState(
@@ -31,40 +41,52 @@ fun EmptyState(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
Icon( // 아이콘 배경
imageVector = icon, Box(
contentDescription = null, modifier = Modifier
modifier = Modifier.size(120.dp), .size(120.dp)
tint = MaterialTheme.colorScheme.outlineVariant .background(
) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
CircleShape
Spacer(modifier = Modifier.height(Spacing.md)) ),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.6f)
)
}
Spacer(modifier = Modifier.height(24.dp))
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
Spacer(modifier = Modifier.height(Spacing.sm)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = message, text = message,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
if (action != null) { if (action != null) {
Spacer(modifier = Modifier.height(Spacing.lg)) Spacer(modifier = Modifier.height(24.dp))
action() action()
} }
} }
} }
/** /**
* 핫딜 없음 상태 * 핫딜 없음 상태 - 개선된 디자인
*/ */
@Composable @Composable
fun NoDealsState( fun NoDealsState(
@@ -74,17 +96,25 @@ fun NoDealsState(
EmptyState( EmptyState(
title = "수집된 핫딜이 없습니다", title = "수집된 핫딜이 없습니다",
message = "새로고침하여 최신 핫딜을 확인해보세요", message = "새로고침하여 최신 핫딜을 확인해보세요",
icon = Icons.Default.ShoppingCart, icon = Icons.Outlined.ShoppingBag,
modifier = modifier, modifier = modifier,
action = { action = {
FilledTonalButton(onClick = onRefresh) { FilledTonalButton(
onClick = onRefresh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.height(48.dp)
) {
Icon( Icon(
imageVector = Icons.Default.Search, imageVector = Icons.Filled.Refresh,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(18.dp) modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "새로고침",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
) )
Spacer(modifier = Modifier.width(Spacing.xs))
Text("새로고침")
} }
} }
) )
@@ -101,13 +131,13 @@ fun NoSearchResultState(
EmptyState( EmptyState(
title = "검색 결과가 없습니다", title = "검색 결과가 없습니다",
message = "'$query'에 대한 결과를 찾을 수 없습니다", message = "'$query'에 대한 결과를 찾을 수 없습니다",
icon = Icons.Default.Search, icon = Icons.Outlined.Search,
modifier = modifier modifier = modifier
) )
} }
/** /**
* 에러 상태 * 에러 상태 - 개선된 디자인
*/ */
@Composable @Composable
fun ErrorState( fun ErrorState(
@@ -118,12 +148,167 @@ fun ErrorState(
EmptyState( EmptyState(
title = "오류가 발생했습니다", title = "오류가 발생했습니다",
message = message, message = message,
icon = Icons.Default.ShoppingCart, icon = Icons.Outlined.ErrorOutline,
modifier = modifier, modifier = modifier,
action = { action = {
FilledTonalButton(onClick = onRetry) { FilledTonalButton(
Text("다시 시도") onClick = onRetry,
shape = RoundedCornerShape(16.dp),
colors = ButtonDefaults.filledTonalButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
),
modifier = Modifier.height(48.dp)
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "다시 시도",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Medium
)
} }
} }
) )
} }
/**
* 로딩 스켈레톤 - 개선된 디자인
*/
@Composable
fun DealListSkeleton(
count: Int = 5,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
repeat(count) { index ->
DealItemSkeleton(
isVisible = true,
delayMillis = index * 100
)
}
}
}
@Composable
private fun DealItemSkeleton(
isVisible: Boolean,
delayMillis: Int = 0
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(delayMillis.toLong())
visible = true
}
AnimatedVisibility(
visible = visible && isVisible,
enter = fadeIn(animationSpec = tween(300)) + slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 4 }
)
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// 상단 스켈레톤
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 사이트 뱃지 스켈레톤
Box(
modifier = Modifier
.height(28.dp)
.width(80.dp)
.clip(RoundedCornerShape(10.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
)
Spacer(modifier = Modifier.width(8.dp))
// 게시판 스켈레톤
Box(
modifier = Modifier
.height(24.dp)
.width(60.dp)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
)
Spacer(modifier = Modifier.weight(1f))
// 액션 버튼 스켈레톤
Box(
modifier = Modifier
.size(36.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
)
}
Spacer(modifier = Modifier.height(12.dp))
// 제목 스켈레톤
Box(
modifier = Modifier
.fillMaxWidth(0.9f)
.height(20.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
)
Spacer(modifier = Modifier.height(8.dp))
Box(
modifier = Modifier
.fillMaxWidth(0.6f)
.height(20.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
)
Spacer(modifier = Modifier.height(10.dp))
// 하단 스켈레톤
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.height(14.dp)
.width(60.dp)
.clip(RoundedCornerShape(4.dp))
.background(MaterialTheme.colorScheme.surfaceVariant)
)
Spacer(modifier = Modifier.weight(1f))
Box(
modifier = Modifier
.size(16.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceVariant)
)
}
}
}
}
}

View File

@@ -3,23 +3,27 @@ package com.hotdeal.alarm.presentation.deallist
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
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.Refresh import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.FilterList import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hotdeal.alarm.domain.model.SiteType import com.hotdeal.alarm.domain.model.SiteType
@@ -31,7 +35,10 @@ import com.hotdeal.alarm.ui.theme.getSiteColor
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DealListScreen(viewModel: MainViewModel) { fun DealListScreen(
viewModel: MainViewModel,
onNavigateToSettings: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
@@ -40,107 +47,179 @@ fun DealListScreen(viewModel: MainViewModel) {
var showFilterMenu by remember { mutableStateOf(false) } var showFilterMenu by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// 개선된 TopAppBar
TopAppBar( TopAppBar(
title = { title = {
Text( Row(
text = "핫딜 목록", verticalAlignment = Alignment.CenterVertically
style = MaterialTheme.typography.headlineSmall ) {
) Box(
}, modifier = Modifier
actions = { .size(36.dp)
// 필터 버튼 .background(
IconButton(onClick = { showFilterMenu = !showFilterMenu }) { MaterialTheme.colorScheme.primary,
Icon( CircleShape
imageVector = Icons.Default.FilterList, ),
contentDescription = "필터" contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Notifications,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "핫딜 알람",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
) )
} }
// 새로고침 버튼 },
actions = {
BadgedBox(
badge = {
if (selectedSiteFilter != null) {
Badge(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
}
}
) {
IconButton(onClick = { showFilterMenu = !showFilterMenu }) {
Icon(
imageVector = if (showFilterMenu) Icons.Filled.FilterList else Icons.Outlined.FilterList,
contentDescription = "필터"
)
}
}
IconButton(onClick = { viewModel.refresh() }) { IconButton(onClick = { viewModel.refresh() }) {
Icon( Icon(
imageVector = Icons.Default.Refresh, imageVector = Icons.Outlined.Refresh,
contentDescription = "새로고침" contentDescription = "새로고침"
) )
} }
IconButton(onClick = { onNavigateToSettings() }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "설정"
)
}
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface containerColor = MaterialTheme.colorScheme.surface
) )
) )
// 사이트 필터 칩들 // 사이트 필터 칩들 - 개선된 디자인
AnimatedVisibility( AnimatedVisibility(
visible = showFilterMenu, visible = showFilterMenu,
enter = expandVertically() + fadeIn(), enter = expandVertically(
exit = shrinkVertically() + fadeOut() animationSpec = spring(stiffness = Spring.StiffnessLow)
) + fadeIn(),
exit = shrinkVertically(
animationSpec = spring(stiffness = Spring.StiffnessLow)
) + fadeOut()
) { ) {
Column( Card(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = Spacing.md, vertical = Spacing.sm) .padding(horizontal = 16.dp, vertical = 8.dp),
) { shape = RoundedCornerShape(16.dp),
Text( colors = CardDefaults.cardColors(
text = "사이트 필터", containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = Spacing.sm)
) )
) {
// 전체 보기 칩 + 사이트별 필터 칩 (가로 스크롤) Column(
Row( modifier = Modifier.padding(16.dp)
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(Spacing.xs)
) { ) {
// 전체 보기 칩 Row(
FilterChip( verticalAlignment = Alignment.CenterVertically
selected = selectedSiteFilter == null, ) {
onClick = { selectedSiteFilter = null }, Icon(
label = { Text("전체") } imageVector = Icons.Outlined.FilterAlt,
) contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
// 사이트별 필터 칩 modifier = Modifier.size(20.dp)
SiteType.entries.forEach { siteType ->
val color = getSiteColor(siteType)
FilterChip(
selected = selectedSiteFilter == siteType,
onClick = {
selectedSiteFilter = if (selectedSiteFilter == siteType) null else siteType
},
label = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.background(color, MaterialTheme.shapes.small)
)
Text(siteType.displayName)
}
},
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = color.copy(alpha = 0.2f),
selectedLabelColor = color
)
) )
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "사이트 필터",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(12.dp))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
EnhancedFilterChip(
selected = selectedSiteFilter == null,
onClick = { selectedSiteFilter = null },
label = "전체",
color = MaterialTheme.colorScheme.primary
)
SiteType.entries.forEach { siteType ->
val color = getSiteColor(siteType)
EnhancedFilterChip(
selected = selectedSiteFilter == siteType,
onClick = {
selectedSiteFilter = if (selectedSiteFilter == siteType) null else siteType
},
label = siteType.displayName,
color = color
)
}
} }
} }
} }
} }
// 검색창 // 검색창 - 개선된 디자인
OutlinedTextField( OutlinedTextField(
value = searchText, value = searchText,
onValueChange = { searchText = it }, onValueChange = { searchText = it },
label = { Text("제목으로 검색") }, placeholder = {
Text(
text = "제목으로 검색...",
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = Spacing.md, vertical = Spacing.sm), .padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true, singleLine = true,
shape = MaterialTheme.shapes.medium shape = RoundedCornerShape(16.dp),
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = "검색",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingIcon = {
if (searchText.isNotEmpty()) {
IconButton(onClick = { searchText = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "지우기",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
) )
when (val state = uiState) { when (val state = uiState) {
@@ -149,17 +228,12 @@ fun DealListScreen(viewModel: MainViewModel) {
} }
is MainUiState.Success -> { is MainUiState.Success -> {
// 필터링 적용
val filteredDeals = remember(state.deals, searchText, selectedSiteFilter) { val filteredDeals = remember(state.deals, searchText, selectedSiteFilter) {
state.deals.filter { deal -> state.deals.filter { deal ->
// 검색어 필터 val matchesSearch = searchText.isBlank() ||
val matchesSearch = searchText.isBlank() ||
deal.title.contains(searchText, ignoreCase = true) deal.title.contains(searchText, ignoreCase = true)
val matchesSite = selectedSiteFilter == null ||
// 사이트 필터
val matchesSite = selectedSiteFilter == null ||
deal.siteType == selectedSiteFilter deal.siteType == selectedSiteFilter
matchesSearch && matchesSite matchesSearch && matchesSite
} }
} }
@@ -177,22 +251,47 @@ fun DealListScreen(viewModel: MainViewModel) {
EmptyState( EmptyState(
title = "결과가 없습니다", title = "결과가 없습니다",
message = message, message = message,
icon = Icons.Default.Search icon = Icons.Outlined.Search
) )
} }
} else { } else {
// 핫딜 개수 표시 Surface(
Text( modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
text = "${filteredDeals.size}개의 핫딜", shape = RoundedCornerShape(12.dp),
style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
color = MaterialTheme.colorScheme.onSurfaceVariant, ) {
modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs) Row(
) modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Inventory2,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "${filteredDeals.size}개의 핫딜",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (selectedSiteFilter != null) {
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "${selectedSiteFilter.displayName}",
style = MaterialTheme.typography.labelMedium,
color = getSiteColor(selectedSiteFilter)
)
}
}
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(Spacing.md), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(Spacing.sm) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
items( items(
items = filteredDeals, items = filteredDeals,
@@ -200,8 +299,12 @@ fun DealListScreen(viewModel: MainViewModel) {
) { deal -> ) { deal ->
AnimatedVisibility( AnimatedVisibility(
visible = true, visible = true,
enter = fadeIn() + slideInVertically(), enter = fadeIn(animationSpec = tween(300)) +
exit = fadeOut() + slideOutVertically() slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 8 }
),
exit = fadeOut(animationSpec = tween(200))
) { ) {
DealItem( DealItem(
deal = deal, deal = deal,
@@ -212,6 +315,10 @@ fun DealListScreen(viewModel: MainViewModel) {
) )
} }
} }
item {
Spacer(modifier = Modifier.height(16.dp))
}
} }
} }
} }
@@ -225,3 +332,48 @@ fun DealListScreen(viewModel: MainViewModel) {
} }
} }
} }
@Composable
private fun EnhancedFilterChip(
selected: Boolean,
onClick: () -> Unit,
label: String,
color: Color
) {
val scale by animateFloatAsState(
targetValue = if (selected) 1.05f else 1f,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "chip_scale"
)
Surface(
onClick = onClick,
modifier = Modifier
.height(36.dp)
.scale(scale),
shape = RoundedCornerShape(18.dp),
color = if (selected) color else MaterialTheme.colorScheme.surface,
border = if (!selected) androidx.compose.foundation.BorderStroke(1.dp, color.copy(alpha = 0.3f)) else null
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
color = if (selected) Color.White else color
)
}
}
}

View File

@@ -6,27 +6,20 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.hotdeal.alarm.presentation.components.PermissionDialog import com.hotdeal.alarm.presentation.components.PermissionDialog
import com.hotdeal.alarm.presentation.deallist.DealListScreen import com.hotdeal.alarm.presentation.deallist.DealListScreen
import com.hotdeal.alarm.presentation.settings.SettingsScreen import com.hotdeal.alarm.presentation.settings.SettingsScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel, viewModel: MainViewModel,
@@ -34,7 +27,7 @@ fun MainScreen(
) { ) {
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showPermissionDialog by remember { mutableStateOf(false) } var showPermissionDialog by remember { mutableStateOf(false) }
val notificationPermissionLauncher = rememberLauncherForActivityResult( val notificationPermissionLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission() contract = ActivityResultContracts.RequestPermission()
@@ -43,7 +36,7 @@ fun MainScreen(
showPermissionDialog = true showPermissionDialog = true
} }
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = ContextCompat.checkSelfPermission( val permission = ContextCompat.checkSelfPermission(
@@ -55,40 +48,23 @@ fun MainScreen(
} }
} }
} }
Scaffold( // TopAppBar 제거 - 화면을 넓게 사용
topBar = { NavHost(
TopAppBar( navController = navController,
title = { Text("핫딜 알람") }, startDestination = Screen.DealList.route
actions = { ) {
// 설정 버튼 composable(Screen.DealList.route) {
IconButton(onClick = { navController.navigate(Screen.Settings.route) }) { DealListScreen(
Icon( viewModel = viewModel,
imageVector = Icons.Default.Settings, onNavigateToSettings = { navController.navigate(Screen.Settings.route) }
contentDescription = "설정" )
) }
} composable(Screen.Settings.route) {
}, SettingsScreen(viewModel = viewModel)
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors( }
containerColor = MaterialTheme.colorScheme.surface }
)
)
}
) { padding ->
NavHost(
navController = navController,
startDestination = Screen.DealList.route,
modifier = Modifier.padding(padding)
) {
composable(Screen.DealList.route) {
DealListScreen(viewModel = viewModel)
}
composable(Screen.Settings.route) {
SettingsScreen(viewModel = viewModel)
}
}
}
if (showPermissionDialog) { if (showPermissionDialog) {
PermissionDialog( PermissionDialog(
title = "알림 권한 필요", title = "알림 권한 필요",
@@ -103,5 +79,3 @@ sealed class Screen(val route: String) {
object DealList : Screen("deal_list") object DealList : Screen("deal_list")
object Settings : Screen("settings") object Settings : Screen("settings")
} }

View File

@@ -4,13 +4,16 @@
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<!-- 배경 원 -->
<path <path
android:fillColor="#FFFFFF" android:fillColor="#FFFFFF"
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/> android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/>
<!-- 알림 벨 아이콘 -->
<path <path
android:fillColor="#1976D2" android:fillColor="#1976D2"
android:pathData="M54,30L54,54L74,54A20,20 0,0 0,54 34L54,30A24,24 0,0 1,78 54L54,54L54,78A24,24 0,0 1,30 54A24,24 0,0 1,54 30Z"/> android:pathData="M54,28C52.9,28 52,28.9 52,30L52,32C47.6,33.1 44,37.1 44,42L44,54L40,58L40,60L68,60L68,58L64,54L64,42C64,37.1 60.4,33.1 56,32L56,30C56,28.9 55.1,28 54,28ZM50,62C50,64.2 51.8,66 54,66C56.2,66 58,64.2 58,62L50,62Z"/>
<!-- 핫딜 느낌의 불꽃 -->
<path <path
android:fillColor="#FF9800" android:fillColor="#FF5722"
android:pathData="M54,38m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"/> android:pathData="M70,38C70,38 72,42 72,46C72,50 68,52 68,52C68,52 70,48 68,44C66,40 70,38 70,38Z"/>
</vector> </vector>

View File

@@ -1,16 +1,16 @@
{ {
"version": "1.2.0", "version": "1.3.0",
"versionCode": 4, "versionCode": 5,
"minSdk": 31, "minSdk": 31,
"targetSdk": 35, "targetSdk": 35,
"forceUpdate": false, "forceUpdate": false,
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases", "updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases",
"changelog": [ "changelog": [
"프로덕션 수준 UI/UX 개선", "아이콘 디자인 개선 (알림 벨 + 불꽃)",
"업데이트 체크 기능 강화", "메인 화면 헤더 제거로 화면 넓게 사용",
"헤더 제거로 화면 공간 확보", "설정 화면 알림 설정 UI 대폭 개선",
"리마인더 알림 설정 추가", "키워드 카드 세련된 디자인 적용",
"버전 동기화 수정" "전체 UI/UX 모던하게 고도화",
] "필터 칩 애니메이션 추가"
} ]
} }