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

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

View File

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

View File

@@ -8,16 +8,16 @@ import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.filled.FavoriteBorder import androidx.compose.material.icons.outlined.*
import androidx.compose.material.icons.filled.Star
import androidx.compose.material.icons.filled.Share
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
@@ -27,6 +27,7 @@ import com.hotdeal.alarm.ui.theme.Spacing
import com.hotdeal.alarm.ui.theme.getSiteColor import com.hotdeal.alarm.ui.theme.getSiteColor
import com.hotdeal.alarm.util.ShareHelper import com.hotdeal.alarm.util.ShareHelper
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DealItem( fun DealItem(
deal: HotDeal, deal: HotDeal,
@@ -37,7 +38,7 @@ fun DealItem(
val context = LocalContext.current val context = LocalContext.current
val favoriteScale by animateFloatAsState( val favoriteScale by animateFloatAsState(
targetValue = if (isFavorite) 1.3f else 1f, targetValue = if (isFavorite) 1.2f else 1f,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow stiffness = Spring.StiffnessLow
@@ -47,57 +48,83 @@ fun DealItem(
val siteColor = getSiteColor(deal.siteType) val siteColor = getSiteColor(deal.siteType)
// 키워드 매칭된 핫딜은 더 강한 시각적 강조 // 키워드 매칭 강조
val cardColor = if (deal.isKeywordMatch) { val cardColors = if (deal.isKeywordMatch) {
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)
} else { } else {
MaterialTheme.colorScheme.surface CardDefaults.elevatedCardColors(
} containerColor = 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()
} }
ElevatedCard( ElevatedCard(
modifier = cardModifier, modifier = modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.large, shape = RoundedCornerShape(20.dp),
colors = CardDefaults.elevatedCardColors(containerColor = cardColor), colors = cardColors,
elevation = CardDefaults.elevatedCardElevation( elevation = CardDefaults.elevatedCardElevation(
defaultElevation = if (deal.isKeywordMatch) 4.dp else 2.dp defaultElevation = if (deal.isKeywordMatch) 6.dp else 2.dp
), ),
onClick = onClick onClick = onClick
) { ) {
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( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(Spacing.md) .padding(16.dp)
.then(
if (deal.isKeywordMatch) {
Modifier.padding(start = 8.dp)
} else {
Modifier
}
)
) { ) {
// 상단: 사이트 뱃지 + 게시판 + 액션 // 상단: 사이트 뱃지 + 게시판 + 액션
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 사이트 뱃지 (색상으로 구분) // 사이트 뱃지 (개선된 디자인)
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(10.dp),
color = siteColor.copy(alpha = 0.15f), color = siteColor.copy(alpha = 0.12f),
modifier = Modifier.height(26.dp) modifier = Modifier.height(28.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 10.dp)
) { ) {
// 색상 점
Box( Box(
modifier = Modifier modifier = Modifier
.size(8.dp) .size(8.dp)
@@ -105,59 +132,71 @@ fun DealItem(
) )
Text( Text(
text = deal.siteType?.displayName ?: deal.siteName, text = deal.siteType?.displayName ?: deal.siteName,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Medium,
color = siteColor 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(
text = deal.boardDisplayName, text = deal.boardDisplayName,
style = MaterialTheme.typography.labelSmall, 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)) Spacer(modifier = Modifier.weight(1f))
// 키워드 매칭 배지 - 별 아이콘으로 변경 // 키워드 매칭 배지
if (deal.isKeywordMatch) { if (deal.isKeywordMatch) {
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.height(26.dp) modifier = Modifier.height(28.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) modifier = Modifier.padding(horizontal = 10.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Star, imageVector = Icons.Filled.Star,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary, tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(14.dp) modifier = Modifier.size(14.dp)
) )
Text( Text(
text = "내 키워드", text = "내 키워드",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary color = MaterialTheme.colorScheme.onPrimary
) )
} }
} }
Spacer(modifier = Modifier.width(Spacing.xs)) Spacer(modifier = Modifier.width(4.dp))
} }
// 액션 버튼들
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp)
) {
// 공유 버튼 // 공유 버튼
IconButton( IconButton(
onClick = { ShareHelper.shareDeal(context, deal) }, onClick = { ShareHelper.shareDeal(context, deal) },
modifier = Modifier.size(32.dp) modifier = Modifier.size(36.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Default.Share, imageVector = Icons.Outlined.Share,
contentDescription = "공유", contentDescription = "공유",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
@@ -167,10 +206,12 @@ fun DealItem(
// 즐겨찾기 버튼 // 즐겨찾기 버튼
IconButton( IconButton(
onClick = { isFavorite = !isFavorite }, onClick = { isFavorite = !isFavorite },
modifier = Modifier.size(32.dp).scale(favoriteScale) modifier = Modifier
.size(36.dp)
.scale(favoriteScale)
) { ) {
Icon( 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 "즐겨찾기 추가", contentDescription = if (isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가",
tint = if (isFavorite) tint = if (isFavorite)
MaterialTheme.colorScheme.error MaterialTheme.colorScheme.error
@@ -180,10 +221,11 @@ fun DealItem(
) )
} }
} }
}
Spacer(modifier = Modifier.height(Spacing.sm)) Spacer(modifier = Modifier.height(12.dp))
// 제목 (키워드 매칭 시 굵게) // 제목
Text( Text(
text = deal.title, text = deal.title,
style = if (deal.isKeywordMatch) { style = if (deal.isKeywordMatch) {
@@ -196,15 +238,58 @@ fun DealItem(
color = MaterialTheme.colorScheme.onSurface 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(
text = formatTime(deal.createdAt), text = formatTime(deal.createdAt),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant 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)
} }
} }

View File

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

View File

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

View File

@@ -6,27 +6,20 @@ import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController import androidx.navigation.NavHostController
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import com.hotdeal.alarm.presentation.components.PermissionDialog import com.hotdeal.alarm.presentation.components.PermissionDialog
import com.hotdeal.alarm.presentation.deallist.DealListScreen import com.hotdeal.alarm.presentation.deallist.DealListScreen
import com.hotdeal.alarm.presentation.settings.SettingsScreen import com.hotdeal.alarm.presentation.settings.SettingsScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun MainScreen( fun MainScreen(
viewModel: MainViewModel, viewModel: MainViewModel,
@@ -56,37 +49,20 @@ fun MainScreen(
} }
} }
Scaffold( // TopAppBar 제거 - 화면을 넓게 사용
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( NavHost(
navController = navController, navController = navController,
startDestination = Screen.DealList.route, startDestination = Screen.DealList.route
modifier = Modifier.padding(padding)
) { ) {
composable(Screen.DealList.route) { composable(Screen.DealList.route) {
DealListScreen(viewModel = viewModel) DealListScreen(
viewModel = viewModel,
onNavigateToSettings = { navController.navigate(Screen.Settings.route) }
)
} }
composable(Screen.Settings.route) { composable(Screen.Settings.route) {
SettingsScreen(viewModel = viewModel) SettingsScreen(viewModel = viewModel)
} }
}
} }
if (showPermissionDialog) { if (showPermissionDialog) {
@@ -103,5 +79,3 @@ sealed class Screen(val route: String) {
object DealList : Screen("deal_list") object DealList : Screen("deal_list")
object Settings : Screen("settings") object Settings : Screen("settings")
} }

View File

@@ -1,21 +1,39 @@
package com.hotdeal.alarm.presentation.settings package com.hotdeal.alarm.presentation.settings
import android.Manifest import android.Manifest
import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.provider.Settings
import android.widget.Toast import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.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.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hotdeal.alarm.domain.model.Keyword 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.components.PermissionDialog
import com.hotdeal.alarm.presentation.main.MainUiState import com.hotdeal.alarm.presentation.main.MainUiState
import com.hotdeal.alarm.presentation.main.MainViewModel 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.PermissionHelper
import com.hotdeal.alarm.util.VersionManager import com.hotdeal.alarm.util.VersionManager
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -56,21 +75,32 @@ fun SettingsScreen(viewModel: MainViewModel) {
val hasEnabledSites = (uiState as? MainUiState.Success)?.siteConfigs?.any { it.isEnabled } ?: false val hasEnabledSites = (uiState as? MainUiState.Success)?.siteConfigs?.any { it.isEnabled } ?: false
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize().padding(16.dp), modifier = Modifier
verticalArrangement = Arrangement.spacedBy(16.dp) .fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(vertical = 16.dp)
) { ) {
// 상단 알림 설정 섹션
item { item {
PermissionCard( NotificationSettingsHeader(
permissionStatus = permissionStatus, permissionStatus = permissionStatus,
hasEnabledSites = hasEnabledSites, hasEnabledSites = hasEnabledSites,
onRequestPermission = { onRequestPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) 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 { item {
PollingIntervalCard( PollingIntervalCard(
currentInterval = currentPollingInterval, 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) { when (val state = uiState) {
is MainUiState.Success -> { is MainUiState.Success -> {
SiteType.entries.forEach { site -> SiteType.entries.forEach { site ->
item { item {
SiteCard( EnhancedSiteCard(
siteType = site, siteType = site,
configs = state.siteConfigs.filter { it.siteName == site.name }, configs = state.siteConfigs.filter { it.siteName == site.name },
onToggle = { key, enabled -> viewModel.toggleSiteConfig(key, enabled) } onToggle = { key, enabled -> viewModel.toggleSiteConfig(key, enabled) }
@@ -96,45 +133,35 @@ fun SettingsScreen(viewModel: MainViewModel) {
} }
} }
// 키워드 설정 섹션
item { item {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(8.dp))
Text("키워드 설정", style = MaterialTheme.typography.titleMedium) SectionHeader(
} title = "키워드 알림",
icon = Icons.Outlined.NotificationsActive,
item { description = "키워드를 등록하면 해당 키워드가 포함된 핫딜 알림"
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.width(8.dp))
FilledIconButton(
onClick = {
if (keywordText.isNotBlank()) {
viewModel.addKeyword(keywordText)
keywordText = ""
}
}
) {
Icon(Icons.Default.Add, contentDescription = "추가")
}
}
} }
items(state.keywords) { keyword -> item {
KeywordItem( KeywordInputCard(
onAdd = { keyword ->
viewModel.addKeyword(keyword)
}
)
}
items(state.keywords, key = { it.id }) { keyword ->
EnhancedKeywordCard(
keyword = keyword, keyword = keyword,
onToggle = { viewModel.toggleKeyword(keyword.id, !keyword.isEnabled) }, onToggle = { viewModel.toggleKeyword(keyword.id, !keyword.isEnabled) },
onDelete = { viewModel.deleteKeyword(keyword.id) } onDelete = { viewModel.deleteKeyword(keyword.id) }
) )
} }
// 버전 정보
item { item {
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(8.dp))
VersionCard( VersionCard(
currentVersion = VersionManager.getCurrentVersion(context), currentVersion = VersionManager.getCurrentVersion(context),
onCheckUpdate = { 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 @Composable
fun PermissionCard( private fun NotificationSettingsHeader(
permissionStatus: PermissionHelper.PermissionStatus, permissionStatus: PermissionHelper.PermissionStatus,
hasEnabledSites: Boolean, hasEnabledSites: Boolean,
onRequestPermission: () -> Unit onRequestPermission: () -> Unit,
onOpenSystemSettings: () -> Unit
) { ) {
Card(modifier = Modifier.fillMaxWidth()) { Card(
Column(modifier = Modifier.padding(16.dp)) { modifier = Modifier.fillMaxWidth(),
Text("알림 설정", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) 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)) 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( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
shape = MaterialTheme.shapes.medium, shape = RoundedCornerShape(16.dp),
color = if (permissionStatus.hasNotificationPermission) color = if (isOk)
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
else else
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
) {
Column(
modifier = Modifier.padding(16.dp)
) { ) {
Row( Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Icon(
imageVector = if (permissionStatus.hasNotificationPermission) Icons.Default.CheckCircle else Icons.Default.Warning, imageVector = icon,
contentDescription = null, contentDescription = null,
tint = if (permissionStatus.hasNotificationPermission) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, tint = if (isOk) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp) modifier = Modifier.size(28.dp)
) )
Spacer(modifier = Modifier.width(12.dp)) Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = if (permissionStatus.hasNotificationPermission) "알림 권한 허용됨" else "알림 권한 필요", text = title,
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold fontWeight = FontWeight.SemiBold
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
text = if (permissionStatus.hasNotificationPermission) text = description,
"키워드 매칭 시 즉시 알림을 받을 수 있습니다"
else
"키워드 매칭 알림을 받으려면 권한을 허용해주세요",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
}
if (!permissionStatus.hasNotificationPermission) { if (action != null || secondaryAction != null) {
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
Button( Row(
onClick = onRequestPermission, modifier = Modifier.fillMaxWidth(),
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( Icon(
imageVector = if (hasEnabledSites) Icons.Default.Check else Icons.Default.Close, imageVector = Icons.Filled.Notifications,
contentDescription = null, 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)) Spacer(modifier = Modifier.width(12.dp))
Column { Column {
Text("사이트 선택", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
Text( Text(
text = if (hasEnabledSites) "모니터링할 사이트가 선택되었습니다" else "최소 1개 사이트를 선택해주세요", text = title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = description,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
} }
} }
} }
@Composable
private fun PollingIntervalCard(
currentInterval: Int,
onIntervalChange: (Long) -> Unit
) {
var selected by remember(currentInterval) { mutableStateOf(currentInterval) }
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
)
} }
} }
@Composable Spacer(modifier = Modifier.height(16.dp))
fun PollingIntervalCard(currentInterval: Int, onIntervalChange: (Long) -> Unit) {
var selected by remember(currentInterval) { mutableStateOf(currentInterval) } // 선택 옵션들
Card(modifier = Modifier.fillMaxWidth()) { val options = listOf(
Column(modifier = Modifier.padding(16.dp)) { 1 to "1분" to "빠름",
Text("폴링 주기", style = MaterialTheme.typography.titleMedium) 2 to "2분" to "권장",
Text("2분 권장", style = MaterialTheme.typography.bodySmall) 5 to "5분" to "보통",
Spacer(modifier = Modifier.height(8.dp)) 10 to "10분" to "느림",
listOf(1, 2, 5, 10, 15, 30).forEach { minutes -> 15 to "15분" to "매우 느림",
Row(verticalAlignment = Alignment.CenterVertically) { 30 to "30분" to "절전"
RadioButton( )
selected = selected == minutes,
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 = { onClick = {
selected = minutes selected = minutes
onIntervalChange(minutes.toLong()) 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 @Composable
fun SiteCard(siteType: SiteType, configs: List<SiteConfig>, onToggle: (String, Boolean) -> Unit) { private fun PollingOptionChip(
Card(modifier = Modifier.fillMaxWidth()) { minutes: Int,
Column(modifier = Modifier.padding(16.dp)) { label: String,
Text(siteType.displayName, style = MaterialTheme.typography.titleSmall) 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 -> configs.forEach { config ->
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically 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( Switch(
checked = config.isEnabled, 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 @Composable
fun KeywordItem(keyword: Keyword, onToggle: () -> Unit, onDelete: () -> Unit) { private fun KeywordInputCard(
ListItem( onAdd: (String) -> Unit
headlineContent = { Text(keyword.keyword) }, ) {
trailingContent = { var keywordText by remember { mutableStateOf("") }
Row { var isExpanded by remember { mutableStateOf(false) }
Switch(checked = keyword.isEnabled, onCheckedChange = { onToggle() })
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "삭제")
}
}
}
)
}
@Composable Card(
fun VersionCard(currentVersion: String, onCheckUpdate: () -> Unit) { modifier = Modifier.fillMaxWidth(),
Card(modifier = Modifier.fillMaxWidth()) { shape = RoundedCornerShape(16.dp),
Column(modifier = Modifier.padding(16.dp)) { colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier.padding(16.dp)
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Column { OutlinedTextField(
Text("앱 버전", style = MaterialTheme.typography.labelMedium) value = keywordText,
Text("v$currentVersion", style = MaterialTheme.typography.titleMedium) 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("업데이트 확인")
} }
} }
} }

View File

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

View File

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