feat: UI/UX 대폭 개선 및 버전 1.3.0 업데이트
- 아이콘 디자인 개선 (알림 벨 + 불꽃) - 메인 화면 헤더 제거로 화면 넓게 사용 - 설정 화면 알림 설정 UI 대폭 개선 - 키워드 카드 세련된 디자인 적용 - 전체 UI/UX 모던하게 고도화 - 필터 칩 애니메이션 추가 - 버전 1.2.0 -> 1.3.0 (versionCode 4 -> 5)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
@@ -47,57 +48,83 @@ fun DealItem(
|
||||
|
||||
val siteColor = getSiteColor(deal.siteType)
|
||||
|
||||
// 키워드 매칭된 핫딜은 더 강한 시각적 강조
|
||||
val cardColor = if (deal.isKeywordMatch) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||
// 키워드 매칭 강조
|
||||
val cardColors = if (deal.isKeywordMatch) {
|
||||
CardDefaults.elevatedCardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
)
|
||||
} 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()
|
||||
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
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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
|
||||
}
|
||||
)
|
||||
) {
|
||||
// 키워드 매칭 인디케이터 (왼쪽)
|
||||
if (deal.isKeywordMatch) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.CenterStart)
|
||||
.width(4.dp)
|
||||
.fillMaxHeight()
|
||||
.background(MaterialTheme.colorScheme.primary)
|
||||
)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(Spacing.md)
|
||||
.padding(16.dp)
|
||||
.then(
|
||||
if (deal.isKeywordMatch) {
|
||||
Modifier.padding(start = 8.dp)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
) {
|
||||
// 상단: 사이트 뱃지 + 게시판 + 액션
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 사이트 뱃지 (색상으로 구분)
|
||||
// 사이트 뱃지 (개선된 디자인)
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = siteColor.copy(alpha = 0.15f),
|
||||
modifier = Modifier.height(26.dp)
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = siteColor.copy(alpha = 0.12f),
|
||||
modifier = Modifier.height(28.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
modifier = Modifier.padding(horizontal = 10.dp)
|
||||
) {
|
||||
// 색상 점
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(8.dp)
|
||||
@@ -105,59 +132,71 @@ fun DealItem(
|
||||
)
|
||||
Text(
|
||||
text = deal.siteType?.displayName ?: deal.siteName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = siteColor
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(Spacing.sm))
|
||||
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 = deal.boardDisplayName,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// 키워드 매칭 배지 - 별 아이콘으로 변경
|
||||
// 키워드 매칭 배지
|
||||
if (deal.isKeywordMatch) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
shape = RoundedCornerShape(10.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.height(26.dp)
|
||||
modifier = Modifier.height(28.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
modifier = Modifier.padding(horizontal = 10.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
imageVector = Icons.Filled.Star,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
text = "내 키워드",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(Spacing.xs))
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
}
|
||||
|
||||
// 액션 버튼들
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// 공유 버튼
|
||||
IconButton(
|
||||
onClick = { ShareHelper.shareDeal(context, deal) },
|
||||
modifier = Modifier.size(32.dp)
|
||||
modifier = Modifier.size(36.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Share,
|
||||
imageVector = Icons.Outlined.Share,
|
||||
contentDescription = "공유",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp)
|
||||
@@ -167,10 +206,12 @@ fun DealItem(
|
||||
// 즐겨찾기 버튼
|
||||
IconButton(
|
||||
onClick = { isFavorite = !isFavorite },
|
||||
modifier = Modifier.size(32.dp).scale(favoriteScale)
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.scale(favoriteScale)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (isFavorite) Icons.Default.Favorite else Icons.Default.FavoriteBorder,
|
||||
imageVector = if (isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
|
||||
contentDescription = if (isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가",
|
||||
tint = if (isFavorite)
|
||||
MaterialTheme.colorScheme.error
|
||||
@@ -180,10 +221,11 @@ fun DealItem(
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(Spacing.sm))
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 제목 (키워드 매칭 시 굵게)
|
||||
// 제목
|
||||
Text(
|
||||
text = deal.title,
|
||||
style = if (deal.isKeywordMatch) {
|
||||
@@ -196,15 +238,58 @@ fun DealItem(
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(Spacing.sm))
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
|
||||
// 하단: 시간 + 추가 정보
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 시간
|
||||
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(
|
||||
imageVector = Icons.Outlined.ArrowForward,
|
||||
contentDescription = "이동",
|
||||
tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -30,24 +40,36 @@ fun EmptyState(
|
||||
.padding(Spacing.lg),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
// 아이콘 배경
|
||||
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(120.dp),
|
||||
tint = MaterialTheme.colorScheme.outlineVariant
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
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 = Icons.Default.FilterList,
|
||||
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)
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
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.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(bottom = Spacing.sm)
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 전체 보기 칩 + 사이트별 필터 칩 (가로 스크롤)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(Spacing.xs)
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// 전체 보기 칩
|
||||
FilterChip(
|
||||
EnhancedFilterChip(
|
||||
selected = selectedSiteFilter == null,
|
||||
onClick = { selectedSiteFilter = null },
|
||||
label = { Text("전체") }
|
||||
label = "전체",
|
||||
color = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
|
||||
// 사이트별 필터 칩
|
||||
SiteType.entries.forEach { siteType ->
|
||||
val color = getSiteColor(siteType)
|
||||
FilterChip(
|
||||
EnhancedFilterChip(
|
||||
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)
|
||||
label = siteType.displayName,
|
||||
color = color
|
||||
)
|
||||
Text(siteType.displayName)
|
||||
}
|
||||
},
|
||||
colors = FilterChipDefaults.filterChipColors(
|
||||
selectedContainerColor = color.copy(alpha = 0.2f),
|
||||
selectedLabelColor = 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 {
|
||||
// 핫딜 개수 표시
|
||||
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.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = Spacing.md, vertical = Spacing.xs)
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,37 +49,20 @@ 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 ->
|
||||
// TopAppBar 제거 - 화면을 넓게 사용
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.DealList.route,
|
||||
modifier = Modifier.padding(padding)
|
||||
startDestination = Screen.DealList.route
|
||||
) {
|
||||
composable(Screen.DealList.route) {
|
||||
DealListScreen(viewModel = viewModel)
|
||||
DealListScreen(
|
||||
viewModel = viewModel,
|
||||
onNavigateToSettings = { navController.navigate(Screen.Settings.route) }
|
||||
)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showPermissionDialog) {
|
||||
@@ -103,5 +79,3 @@ sealed class Screen(val route: String) {
|
||||
object DealList : Screen("deal_list")
|
||||
object Settings : Screen("settings")
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
package com.hotdeal.alarm.presentation.settings
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.widget.Toast
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.animation.core.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.outlined.*
|
||||
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.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.hotdeal.alarm.domain.model.Keyword
|
||||
@@ -24,6 +42,7 @@ import com.hotdeal.alarm.domain.model.SiteType
|
||||
import com.hotdeal.alarm.presentation.components.PermissionDialog
|
||||
import com.hotdeal.alarm.presentation.main.MainUiState
|
||||
import com.hotdeal.alarm.presentation.main.MainViewModel
|
||||
import com.hotdeal.alarm.ui.theme.getSiteColor
|
||||
import com.hotdeal.alarm.util.PermissionHelper
|
||||
import com.hotdeal.alarm.util.VersionManager
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -56,21 +75,32 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
||||
val hasEnabledSites = (uiState as? MainUiState.Success)?.siteConfigs?.any { it.isEnabled } ?: false
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(vertical = 16.dp)
|
||||
) {
|
||||
// 상단 알림 설정 섹션
|
||||
item {
|
||||
PermissionCard(
|
||||
NotificationSettingsHeader(
|
||||
permissionStatus = permissionStatus,
|
||||
hasEnabledSites = hasEnabledSites,
|
||||
onRequestPermission = {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
},
|
||||
onOpenSystemSettings = {
|
||||
val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
|
||||
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 폴링 주기 설정
|
||||
item {
|
||||
PollingIntervalCard(
|
||||
currentInterval = currentPollingInterval,
|
||||
@@ -82,13 +112,20 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
|
||||
item { Text("사이트 선택", style = MaterialTheme.typography.titleMedium) }
|
||||
// 사이트 설정 섹션
|
||||
item {
|
||||
SectionHeader(
|
||||
title = "사이트 선택",
|
||||
icon = Icons.Outlined.Language,
|
||||
description = "모니터링할 사이트를 선택하세요"
|
||||
)
|
||||
}
|
||||
|
||||
when (val state = uiState) {
|
||||
is MainUiState.Success -> {
|
||||
SiteType.entries.forEach { site ->
|
||||
item {
|
||||
SiteCard(
|
||||
EnhancedSiteCard(
|
||||
siteType = site,
|
||||
configs = state.siteConfigs.filter { it.siteName == site.name },
|
||||
onToggle = { key, enabled -> viewModel.toggleSiteConfig(key, enabled) }
|
||||
@@ -96,45 +133,35 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
}
|
||||
|
||||
// 키워드 설정 섹션
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Text("키워드 설정", style = MaterialTheme.typography.titleMedium)
|
||||
}
|
||||
|
||||
item {
|
||||
var keywordText by remember { mutableStateOf("") }
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = keywordText,
|
||||
onValueChange = { keywordText = it },
|
||||
label = { Text("키워드 입력") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
SectionHeader(
|
||||
title = "키워드 알림",
|
||||
icon = Icons.Outlined.NotificationsActive,
|
||||
description = "키워드를 등록하면 해당 키워드가 포함된 핫딜 알림"
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
if (keywordText.isNotBlank()) {
|
||||
viewModel.addKeyword(keywordText)
|
||||
keywordText = ""
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "추가")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
items(state.keywords) { keyword ->
|
||||
KeywordItem(
|
||||
item {
|
||||
KeywordInputCard(
|
||||
onAdd = { keyword ->
|
||||
viewModel.addKeyword(keyword)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
items(state.keywords, key = { it.id }) { keyword ->
|
||||
EnhancedKeywordCard(
|
||||
keyword = keyword,
|
||||
onToggle = { viewModel.toggleKeyword(keyword.id, !keyword.isEnabled) },
|
||||
onDelete = { viewModel.deleteKeyword(keyword.id) }
|
||||
)
|
||||
}
|
||||
|
||||
// 버전 정보
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
VersionCard(
|
||||
currentVersion = VersionManager.getCurrentVersion(context),
|
||||
onCheckUpdate = {
|
||||
@@ -151,7 +178,18 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> { item { CircularProgressIndicator() } }
|
||||
else -> {
|
||||
item {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(32.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,139 +204,461 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PermissionCard(
|
||||
private fun NotificationSettingsHeader(
|
||||
permissionStatus: PermissionHelper.PermissionStatus,
|
||||
hasEnabledSites: Boolean,
|
||||
onRequestPermission: () -> Unit
|
||||
onRequestPermission: () -> Unit,
|
||||
onOpenSystemSettings: () -> Unit
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("알림 설정", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
// 헤더
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Notifications,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "알림 설정",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "핫딜 알림을 받기 위한 설정",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 알림 권한 상태 카드
|
||||
NotificationStatusCard(
|
||||
icon = if (permissionStatus.hasNotificationPermission) Icons.Filled.CheckCircle else Icons.Filled.Warning,
|
||||
title = if (permissionStatus.hasNotificationPermission) "알림 권한 허용됨" else "알림 권한 필요",
|
||||
description = if (permissionStatus.hasNotificationPermission)
|
||||
"키워드 매칭 시 즉시 알림을 받을 수 있습니다"
|
||||
else
|
||||
"알림을 받으려면 권한을 허용해주세요",
|
||||
isOk = permissionStatus.hasNotificationPermission,
|
||||
action = if (!permissionStatus.hasNotificationPermission) {
|
||||
{ onRequestPermission() }
|
||||
} else null,
|
||||
actionLabel = "권한 허용하기",
|
||||
secondaryAction = if (permissionStatus.hasNotificationPermission) {
|
||||
{ onOpenSystemSettings() }
|
||||
} else null,
|
||||
secondaryActionLabel = "시스템 설정"
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 알림 권한 상세 안내
|
||||
// 사이트 선택 상태
|
||||
NotificationStatusCard(
|
||||
icon = if (hasEnabledSites) Icons.Filled.CheckCircle else Icons.Filled.Error,
|
||||
title = if (hasEnabledSites) "사이트 선택 완료" else "사이트 선택 필요",
|
||||
description = if (hasEnabledSites)
|
||||
"모니터링할 사이트가 선택되었습니다"
|
||||
else
|
||||
"최소 1개 이상의 사이트를 선택해주세요",
|
||||
isOk = hasEnabledSites
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NotificationStatusCard(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
description: String,
|
||||
isOk: Boolean,
|
||||
action: (() -> Unit)? = null,
|
||||
actionLabel: String = "",
|
||||
secondaryAction: (() -> Unit)? = null,
|
||||
secondaryActionLabel: String = ""
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (permissionStatus.hasNotificationPermission)
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = if (isOk)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
else
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (permissionStatus.hasNotificationPermission) Icons.Default.CheckCircle else Icons.Default.Warning,
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = if (permissionStatus.hasNotificationPermission) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(32.dp)
|
||||
tint = if (isOk) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (permissionStatus.hasNotificationPermission) "알림 권한 허용됨" else "알림 권한 필요",
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = if (permissionStatus.hasNotificationPermission)
|
||||
"키워드 매칭 시 즉시 알림을 받을 수 있습니다"
|
||||
else
|
||||
"키워드 매칭 알림을 받으려면 권한을 허용해주세요",
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!permissionStatus.hasNotificationPermission) {
|
||||
if (action != null || secondaryAction != null) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = onRequestPermission,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
if (action != null) {
|
||||
Button(
|
||||
onClick = action,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Icon(Icons.Default.Notifications, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("알림 권한 허용하기")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 사이트 선택 상태
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (hasEnabledSites) Icons.Default.Check else Icons.Default.Close,
|
||||
imageVector = Icons.Filled.Notifications,
|
||||
contentDescription = null,
|
||||
tint = if (hasEnabledSites) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(actionLabel)
|
||||
}
|
||||
}
|
||||
if (secondaryAction != null) {
|
||||
OutlinedButton(
|
||||
onClick = secondaryAction,
|
||||
modifier = Modifier.weight(1f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Settings,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(secondaryActionLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionHeader(
|
||||
title: String,
|
||||
icon: ImageVector,
|
||||
description: String
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text("사이트 선택", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
text = if (hasEnabledSites) "모니터링할 사이트가 선택되었습니다" else "최소 1개 사이트를 선택해주세요",
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PollingIntervalCard(currentInterval: Int, onIntervalChange: (Long) -> Unit) {
|
||||
private fun PollingIntervalCard(
|
||||
currentInterval: Int,
|
||||
onIntervalChange: (Long) -> Unit
|
||||
) {
|
||||
var selected by remember(currentInterval) { mutableStateOf(currentInterval) }
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("폴링 주기", style = MaterialTheme.typography.titleMedium)
|
||||
Text("2분 권장", style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
listOf(1, 2, 5, 10, 15, 30).forEach { minutes ->
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
RadioButton(
|
||||
selected = selected == minutes,
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.secondaryContainer,
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Schedule,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text(
|
||||
text = "새로고침 주기",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Text(
|
||||
text = "핫딜을 확인하는 간격입니다",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 선택 옵션들
|
||||
val options = listOf(
|
||||
1 to "1분" to "빠름",
|
||||
2 to "2분" to "권장",
|
||||
5 to "5분" to "보통",
|
||||
10 to "10분" to "느림",
|
||||
15 to "15분" to "매우 느림",
|
||||
30 to "30분" to "절전"
|
||||
)
|
||||
|
||||
options.chunked(3).forEach { rowOptions ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
rowOptions.forEach { (minutes, label, subLabel) ->
|
||||
PollingOptionChip(
|
||||
minutes = minutes,
|
||||
label = label,
|
||||
subLabel = subLabel,
|
||||
isSelected = selected == minutes,
|
||||
onClick = {
|
||||
selected = minutes
|
||||
onIntervalChange(minutes.toLong())
|
||||
}
|
||||
},
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Text(when(minutes) {
|
||||
1 -> "1분 (빠름)"
|
||||
2 -> "2분 (권장)"
|
||||
5 -> "5분 (보통)"
|
||||
10 -> "10분 (느림)"
|
||||
15 -> "15분 (매우 느림)"
|
||||
30 -> "30분 (절전)"
|
||||
else -> "${minutes}분"
|
||||
})
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SiteCard(siteType: SiteType, configs: List<SiteConfig>, onToggle: (String, Boolean) -> Unit) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(siteType.displayName, style = MaterialTheme.typography.titleSmall)
|
||||
private fun PollingOptionChip(
|
||||
minutes: Int,
|
||||
label: String,
|
||||
subLabel: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = when {
|
||||
isPressed -> 0.95f
|
||||
isSelected -> 1.02f
|
||||
else -> 1f
|
||||
},
|
||||
animationSpec = spring(stiffness = Spring.StiffnessLow),
|
||||
label = "chip_scale"
|
||||
)
|
||||
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.scale(scale),
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = if (isSelected)
|
||||
MaterialTheme.colorScheme.primaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border = if (isSelected)
|
||||
null
|
||||
else
|
||||
null,
|
||||
interactionSource = interactionSource
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(vertical = 12.dp)
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
|
||||
color = if (isSelected)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = subLabel,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = if (isSelected)
|
||||
MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnhancedSiteCard(
|
||||
siteType: SiteType,
|
||||
configs: List<SiteConfig>,
|
||||
onToggle: (String, Boolean) -> Unit
|
||||
) {
|
||||
val siteColor = getSiteColor(siteType)
|
||||
val enabledCount = configs.count { it.isEnabled }
|
||||
val totalCount = configs.size
|
||||
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
// 사이트 헤더
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
// 사이트 색상 인디케이터
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.background(siteColor.copy(alpha = 0.15f), CircleShape),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(20.dp)
|
||||
.background(siteColor, CircleShape)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = siteType.displayName,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Text(
|
||||
text = "$enabledCount / $totalCount 게시판 활성화",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// 진행률 표시
|
||||
if (totalCount > 0) {
|
||||
Text(
|
||||
text = "${(enabledCount * 100 / totalCount)}%",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = if (enabledCount > 0) siteColor else MaterialTheme.colorScheme.outline
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 게시판 목록
|
||||
if (configs.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
configs.forEach { config ->
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(config.displayName.substringAfter(" - "), modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
text = config.displayName.substringAfter(" - "),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
Switch(
|
||||
checked = config.isEnabled,
|
||||
onCheckedChange = { onToggle(config.siteBoardKey, it) }
|
||||
onCheckedChange = { onToggle(config.siteBoardKey, it) },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = siteColor,
|
||||
checkedTrackColor = siteColor.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -306,34 +666,252 @@ fun SiteCard(siteType: SiteType, configs: List<SiteConfig>, onToggle: (String, B
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun KeywordItem(keyword: Keyword, onToggle: () -> Unit, onDelete: () -> Unit) {
|
||||
ListItem(
|
||||
headlineContent = { Text(keyword.keyword) },
|
||||
trailingContent = {
|
||||
Row {
|
||||
Switch(checked = keyword.isEnabled, onCheckedChange = { onToggle() })
|
||||
IconButton(onClick = onDelete) {
|
||||
Icon(Icons.Default.Delete, contentDescription = "삭제")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
private fun KeywordInputCard(
|
||||
onAdd: (String) -> Unit
|
||||
) {
|
||||
var keywordText by remember { mutableStateOf("") }
|
||||
var isExpanded by remember { mutableStateOf(false) }
|
||||
|
||||
@Composable
|
||||
fun VersionCard(currentVersion: String, onCheckUpdate: () -> Unit) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column {
|
||||
Text("앱 버전", style = MaterialTheme.typography.labelMedium)
|
||||
Text("v$currentVersion", style = MaterialTheme.typography.titleMedium)
|
||||
OutlinedTextField(
|
||||
value = keywordText,
|
||||
onValueChange = { keywordText = it },
|
||||
label = { Text("새 키워드 입력") },
|
||||
placeholder = { Text("예: 에어팟, 갤럭시 버즈") },
|
||||
modifier = Modifier.weight(1f),
|
||||
singleLine = true,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Tag,
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
Button(onClick = onCheckUpdate) { Text("업데이트 확인") }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
FilledIconButton(
|
||||
onClick = {
|
||||
if (keywordText.isNotBlank()) {
|
||||
onAdd(keywordText)
|
||||
keywordText = ""
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(56.dp),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Add,
|
||||
contentDescription = "추가",
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 힌트
|
||||
AnimatedVisibility(
|
||||
visible = keywordText.isEmpty(),
|
||||
enter = fadeIn() + expandVertically(),
|
||||
exit = fadeOut() + shrinkVertically()
|
||||
) {
|
||||
Text(
|
||||
text = "💡 키워드를 등록하면 해당 키워드가 포함된 핫딜이 올라올 때 알림을 받습니다",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EnhancedKeywordCard(
|
||||
keyword: Keyword,
|
||||
onToggle: () -> Unit,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val scale by animateFloatAsState(
|
||||
targetValue = if (keyword.isEnabled) 1f else 0.98f,
|
||||
animationSpec = spring(stiffness = Spring.StiffnessLow),
|
||||
label = "keyword_scale"
|
||||
)
|
||||
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.scale(scale),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = if (keyword.isEnabled)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||
else
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(
|
||||
defaultElevation = if (keyword.isEnabled) 2.dp else 0.dp
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
// 키워드 아이콘
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.background(
|
||||
if (keyword.isEnabled)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (keyword.isEnabled) Icons.Filled.Tag else Icons.Outlined.Tag,
|
||||
contentDescription = null,
|
||||
tint = if (keyword.isEnabled)
|
||||
MaterialTheme.colorScheme.onPrimary
|
||||
else
|
||||
MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.size(22.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
// 키워드 텍스트
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = keyword.keyword,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = if (keyword.isEnabled)
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f)
|
||||
)
|
||||
Text(
|
||||
text = if (keyword.isEnabled) "알림 활성화됨" else "알림 비활성화",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = if (keyword.isEnabled)
|
||||
MaterialTheme.colorScheme.primary
|
||||
else
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
|
||||
// 토글 스위치
|
||||
Switch(
|
||||
checked = keyword.isEnabled,
|
||||
onCheckedChange = { onToggle() },
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.primary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||
)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
|
||||
// 삭제 버튼
|
||||
IconButton(
|
||||
onClick = onDelete,
|
||||
modifier = Modifier.size(40.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Delete,
|
||||
contentDescription = "삭제",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VersionCard(
|
||||
currentVersion: String,
|
||||
onCheckUpdate: () -> Unit
|
||||
) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
),
|
||||
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.tertiaryContainer,
|
||||
CircleShape
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Info,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "앱 버전",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
Text(
|
||||
text = "v$currentVersion",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onCheckUpdate,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SystemUpdate,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text("업데이트 확인")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
16
version.json
16
version.json
@@ -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 개선",
|
||||
"업데이트 체크 기능 강화",
|
||||
"헤더 제거로 화면 공간 확보",
|
||||
"리마인더 알림 설정 추가",
|
||||
"버전 동기화 수정"
|
||||
"아이콘 디자인 개선 (알림 벨 + 불꽃)",
|
||||
"메인 화면 헤더 제거로 화면 넓게 사용",
|
||||
"설정 화면 알림 설정 UI 대폭 개선",
|
||||
"키워드 카드 세련된 디자인 적용",
|
||||
"전체 UI/UX 모던하게 고도화",
|
||||
"필터 칩 애니메이션 추가"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user