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"
minSdk = 31
targetSdk = 35
versionCode = 4
versionName = "1.2.0"
versionCode = 5
versionName = "1.3.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@@ -8,16 +8,16 @@ 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.Favorite
import androidx.compose.material.icons.filled.FavoriteBorder
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
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.util.ShareHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DealItem(
deal: HotDeal,
@@ -37,7 +38,7 @@ fun DealItem(
val context = LocalContext.current
val favoriteScale by animateFloatAsState(
targetValue = if (isFavorite) 1.3f else 1f,
targetValue = if (isFavorite) 1.2f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
@@ -45,169 +46,253 @@ fun DealItem(
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
} else {
Color.Transparent
}
val cardModifier = if (deal.isKeywordMatch) {
modifier.fillMaxWidth()
.border(3.dp, borderColor, MaterialTheme.shapes.large)
} else {
modifier.fillMaxWidth()
}
// 키워드 매칭 강조
val cardColors = if (deal.isKeywordMatch) {
CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)
} else {
CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
)
}
ElevatedCard(
modifier = cardModifier,
shape = MaterialTheme.shapes.large,
colors = CardDefaults.elevatedCardColors(containerColor = cardColor),
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = cardColors,
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = if (deal.isKeywordMatch) 4.dp else 2.dp
defaultElevation = if (deal.isKeywordMatch) 6.dp else 2.dp
),
onClick = onClick
) {
Column(
Box(
modifier = Modifier
.fillMaxWidth()
.padding(Spacing.md)
.then(
if (deal.isKeywordMatch) {
Modifier.background(
brush = Brush.horizontalGradient(
colors = listOf(
MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
MaterialTheme.colorScheme.primary.copy(alpha = 0.05f)
)
)
)
} else {
Modifier
}
)
) {
// 상단: 사이트 뱃지 + 게시판 + 액션
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
// 키워드 매칭 인디케이터 (왼쪽)
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
}
)
) {
// 사이트 뱃지 (색상으로 구분)
Surface(
shape = RoundedCornerShape(12.dp),
color = siteColor.copy(alpha = 0.15f),
modifier = Modifier.height(26.dp)
// 상단: 사이트 뱃지 + 게시판 + 액션
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
// 사이트 뱃지 (개선된 디자인)
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)
) {
// 색상 점
Box(
modifier = Modifier
.size(8.dp)
.background(siteColor, CircleShape)
)
Text(
text = deal.siteType?.displayName ?: deal.siteName,
text = deal.boardDisplayName,
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 = deal.boardDisplayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
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.weight(1f))
Spacer(modifier = Modifier.height(10.dp))
// 키워드 매칭 배지 - 별 아이콘으로 변경
if (deal.isKeywordMatch) {
Surface(
shape = RoundedCornerShape(12.dp),
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(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Share,
contentDescription = "공유",
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.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
)
}
// 즐겨찾기 버튼
IconButton(
onClick = { isFavorite = !isFavorite },
modifier = Modifier.size(32.dp).scale(favoriteScale)
) {
Spacer(modifier = Modifier.weight(1f))
// 화살표 (클릭 유도)
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)
imageVector = Icons.Outlined.ArrowForward,
contentDescription = "이동",
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
modifier = Modifier.size(16.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 {
val now = System.currentTimeMillis()
val diff = now - timestamp

View File

@@ -1,20 +1,30 @@
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.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.ShoppingCart
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.*
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.graphics.Brush
import androidx.compose.ui.graphics.Color
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.unit.dp
import com.hotdeal.alarm.ui.theme.Spacing
/**
* 빈 상태 컴포넌트
* 빈 상태 컴포넌트 - 개선된 디자인
*/
@Composable
fun EmptyState(
@@ -31,23 +41,35 @@ fun EmptyState(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.outlineVariant
)
// 아이콘 배경
Box(
modifier = Modifier
.size(120.dp)
.background(
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f),
CircleShape
),
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(Spacing.md))
Spacer(modifier = Modifier.height(24.dp))
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(Spacing.sm))
Spacer(modifier = Modifier.height(8.dp))
Text(
text = message,
@@ -57,14 +79,14 @@ fun EmptyState(
)
if (action != null) {
Spacer(modifier = Modifier.height(Spacing.lg))
Spacer(modifier = Modifier.height(24.dp))
action()
}
}
}
/**
* 핫딜 없음 상태
* 핫딜 없음 상태 - 개선된 디자인
*/
@Composable
fun NoDealsState(
@@ -74,17 +96,25 @@ fun NoDealsState(
EmptyState(
title = "수집된 핫딜이 없습니다",
message = "새로고침하여 최신 핫딜을 확인해보세요",
icon = Icons.Default.ShoppingCart,
icon = Icons.Outlined.ShoppingBag,
modifier = modifier,
action = {
FilledTonalButton(onClick = onRefresh) {
FilledTonalButton(
onClick = onRefresh,
shape = RoundedCornerShape(16.dp),
modifier = Modifier.height(48.dp)
) {
Icon(
imageVector = Icons.Default.Search,
imageVector = Icons.Filled.Refresh,
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(
title = "검색 결과가 없습니다",
message = "'$query'에 대한 결과를 찾을 수 없습니다",
icon = Icons.Default.Search,
icon = Icons.Outlined.Search,
modifier = modifier
)
}
/**
* 에러 상태
* 에러 상태 - 개선된 디자인
*/
@Composable
fun ErrorState(
@@ -118,12 +148,167 @@ fun ErrorState(
EmptyState(
title = "오류가 발생했습니다",
message = message,
icon = Icons.Default.ShoppingCart,
icon = Icons.Outlined.ErrorOutline,
modifier = modifier,
action = {
FilledTonalButton(onClick = onRetry) {
Text("다시 시도")
FilledTonalButton(
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.net.Uri
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.filled.Refresh
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.FilterList
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
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.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hotdeal.alarm.domain.model.SiteType
@@ -31,7 +35,10 @@ import com.hotdeal.alarm.ui.theme.getSiteColor
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DealListScreen(viewModel: MainViewModel) {
fun DealListScreen(
viewModel: MainViewModel,
onNavigateToSettings: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
@@ -40,107 +47,179 @@ fun DealListScreen(viewModel: MainViewModel) {
var showFilterMenu by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) {
// 개선된 TopAppBar
TopAppBar(
title = {
Text(
text = "핫딜 목록",
style = MaterialTheme.typography.headlineSmall
)
},
actions = {
// 필터 버튼
IconButton(onClick = { showFilterMenu = !showFilterMenu }) {
Icon(
imageVector = Icons.Default.FilterList,
contentDescription = "필터"
Row(
verticalAlignment = Alignment.CenterVertically
) {
Box(
modifier = Modifier
.size(36.dp)
.background(
MaterialTheme.colorScheme.primary,
CircleShape
),
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() }) {
Icon(
imageVector = Icons.Default.Refresh,
imageVector = Icons.Outlined.Refresh,
contentDescription = "새로고침"
)
}
IconButton(onClick = { onNavigateToSettings() }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "설정"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
// 사이트 필터 칩들
// 사이트 필터 칩들 - 개선된 디자인
AnimatedVisibility(
visible = showFilterMenu,
enter = expandVertically() + fadeIn(),
exit = shrinkVertically() + fadeOut()
enter = expandVertically(
animationSpec = spring(stiffness = Spring.StiffnessLow)
) + fadeIn(),
exit = shrinkVertically(
animationSpec = spring(stiffness = Spring.StiffnessLow)
) + fadeOut()
) {
Column(
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md, vertical = Spacing.sm)
) {
Text(
text = "사이트 필터",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = Spacing.sm)
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
// 전체 보기 칩 + 사이트별 필터 칩 (가로 스크롤)
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(Spacing.xs)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
// 전체 보기 칩
FilterChip(
selected = selectedSiteFilter == null,
onClick = { selectedSiteFilter = null },
label = { Text("전체") }
)
// 사이트별 필터 칩
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
)
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.FilterAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
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(
value = searchText,
onValueChange = { searchText = it },
label = { Text("제목으로 검색") },
placeholder = {
Text(
text = "제목으로 검색...",
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = Spacing.md, vertical = Spacing.sm),
.padding(horizontal = 16.dp, vertical = 8.dp),
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) {
@@ -149,17 +228,12 @@ fun DealListScreen(viewModel: MainViewModel) {
}
is MainUiState.Success -> {
// 필터링 적용
val filteredDeals = remember(state.deals, searchText, selectedSiteFilter) {
state.deals.filter { deal ->
// 검색어 필터
val matchesSearch = searchText.isBlank() ||
deal.title.contains(searchText, ignoreCase = true)
// 사이트 필터
val matchesSite = selectedSiteFilter == null ||
deal.siteType == selectedSiteFilter
matchesSearch && matchesSite
}
}
@@ -177,22 +251,47 @@ fun DealListScreen(viewModel: MainViewModel) {
EmptyState(
title = "결과가 없습니다",
message = message,
icon = Icons.Default.Search
icon = Icons.Outlined.Search
)
}
} else {
// 핫딜 개수 표시
Text(
text = "${filteredDeals.size}개의 핫딜",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs)
)
Surface(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
) {
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(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(Spacing.md),
verticalArrangement = Arrangement.spacedBy(Spacing.sm)
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = filteredDeals,
@@ -200,8 +299,12 @@ fun DealListScreen(viewModel: MainViewModel) {
) { deal ->
AnimatedVisibility(
visible = true,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically()
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 8 }
),
exit = fadeOut(animationSpec = tween(200))
) {
DealItem(
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.result.contract.ActivityResultContracts
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.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.hotdeal.alarm.presentation.components.PermissionDialog
import com.hotdeal.alarm.presentation.deallist.DealListScreen
import com.hotdeal.alarm.presentation.settings.SettingsScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
viewModel: MainViewModel,
@@ -56,38 +49,21 @@ fun MainScreen(
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("핫딜 알람") },
actions = {
// 설정 버튼
IconButton(onClick = { navController.navigate(Screen.Settings.route) }) {
Icon(
imageVector = Icons.Default.Settings,
contentDescription = "설정"
)
}
},
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)
}
}
}
// TopAppBar 제거 - 화면을 넓게 사용
NavHost(
navController = navController,
startDestination = Screen.DealList.route
) {
composable(Screen.DealList.route) {
DealListScreen(
viewModel = viewModel,
onNavigateToSettings = { navController.navigate(Screen.Settings.route) }
)
}
composable(Screen.Settings.route) {
SettingsScreen(viewModel = viewModel)
}
}
if (showPermissionDialog) {
PermissionDialog(
@@ -103,5 +79,3 @@ sealed class Screen(val route: String) {
object DealList : Screen("deal_list")
object Settings : Screen("settings")
}

View File

@@ -4,13 +4,16 @@
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<!-- 배경 원 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M54,54m-40,0a40,40 0,1 1,80 0a40,40 0,1 1,-80 0"/>
<!-- 알림 벨 아이콘 -->
<path
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
android:fillColor="#FF9800"
android:pathData="M54,38m-8,0a8,8 0,1 1,16 0a8,8 0,1 1,-16 0"/>
android:fillColor="#FF5722"
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>

View File

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