diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c95c131..458ef7c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "com.hotdeal.alarm" minSdk = 31 targetSdk = 35 - versionCode = 11 - versionName = "1.5.0" + versionCode = 12 + versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4130517..6276324 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -20,6 +20,9 @@ + + + (null) } var showFavoritesOnly by remember { mutableStateOf(false) } @@ -108,252 +123,274 @@ fun DealListScreen( ) ) }, - contentWindowInsets = WindowInsets.systemBars + contentWindowInsets = WindowInsets(0, 0, 0, 0) ) { paddingValues -> - Column( + Box( modifier = Modifier .fillMaxSize() - .padding(paddingValues) + .nestedScroll(pullToRefreshState.nestedScrollConnection) ) { - // 필터 메뉴 - AnimatedVisibility( - visible = showFilterMenu, - enter = expandVertically(animationSpec = spring(stiffness = Spring.StiffnessLow)) + fadeIn(), - exit = shrinkVertically(animationSpec = spring(stiffness = Spring.StiffnessLow)) + fadeOut() + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .consumeWindowInsets(paddingValues) ) { - Card( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) - ) + // 필터 메뉴 + AnimatedVisibility( + visible = showFilterMenu, + enter = expandVertically(animationSpec = spring(stiffness = Spring.StiffnessLow)) + fadeIn(), + exit = shrinkVertically(animationSpec = spring(stiffness = Spring.StiffnessLow)) + fadeOut() ) { - 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.titleSmall, - fontWeight = FontWeight.Bold - ) - } + Card( + modifier = Modifier + .fillMaxWidth() + .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.titleSmall, + fontWeight = FontWeight.Bold + ) + } - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - EnhancedFilterChip( - selected = selectedSiteFilter == null, - onClick = { selectedSiteFilter = null }, - label = "전체", - color = MaterialTheme.colorScheme.primary - ) - - SiteType.entries.forEach { siteType -> - val color = getSiteColor(siteType) + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { EnhancedFilterChip( - selected = selectedSiteFilter == siteType, - onClick = { - selectedSiteFilter = if (selectedSiteFilter == siteType) null else siteType - }, - label = siteType.displayName, - color = color + selected = selectedSiteFilter == null, + onClick = { selectedSiteFilter = null }, + label = "전체", + color = MaterialTheme.colorScheme.primary + ) + + SiteType.entries.forEach { siteType -> + val color = getSiteColor(siteType) + EnhancedFilterChip( + selected = selectedSiteFilter == siteType, + onClick = { + selectedSiteFilter = if (selectedSiteFilter == siteType) null else siteType + }, + label = siteType.displayName, + color = color + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + // 특수 필터 + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + // 내 키워드 필터 + EnhancedFilterChip( + selected = showKeywordMatchOnly, + onClick = { showKeywordMatchOnly = !showKeywordMatchOnly }, + label = "내 키워드", + color = MaterialTheme.colorScheme.primary + ) + + // 즐겨찾기 필터 + EnhancedFilterChip( + selected = showFavoritesOnly, + onClick = { showFavoritesOnly = !showFavoritesOnly }, + label = "즐겨찾기", + color = MaterialTheme.colorScheme.error ) } } + } + } - Spacer(modifier = Modifier.height(8.dp)) - - // 특수 필터 - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - // 내 키워드 필터 - EnhancedFilterChip( - selected = showKeywordMatchOnly, - onClick = { showKeywordMatchOnly = !showKeywordMatchOnly }, - label = "내 키워드", - color = MaterialTheme.colorScheme.primary - ) - - // 즐겨찾기 필터 - EnhancedFilterChip( - selected = showFavoritesOnly, - onClick = { showFavoritesOnly = !showFavoritesOnly }, - label = "즐겨찾기", - color = MaterialTheme.colorScheme.error - ) + // 검색창 + OutlinedTextField( + value = searchText, + onValueChange = { searchText = it }, + placeholder = { + Text( + text = "제목으로 검색...", + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + singleLine = true, + 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) { + is MainUiState.Loading -> { + DealListSkeleton(count = 5) + } + + is MainUiState.Success -> { + val filteredDeals = remember(state.deals, searchText, selectedSiteFilter, showFavoritesOnly, showKeywordMatchOnly) { + state.deals.filter { deal -> + val matchesSearch = searchText.isBlank() || deal.title.contains(searchText, ignoreCase = true) + val matchesSite = selectedSiteFilter == null || deal.siteType == selectedSiteFilter + val matchesFavorite = !showFavoritesOnly || deal.isFavorite + val matchesKeyword = !showKeywordMatchOnly || deal.isKeywordMatch + matchesSearch && matchesSite && matchesFavorite && matchesKeyword + } + } + + if (filteredDeals.isEmpty()) { + if (state.deals.isEmpty()) { + NoDealsState(onRefresh = { viewModel.refresh() }) + } else { + val selectedFilter = selectedSiteFilter + val message = when { + showKeywordMatchOnly -> "키워드 매칭된 핫딜이 없습니다" + showFavoritesOnly -> "즐겨찾기한 핫딜이 없습니다" + selectedFilter != null -> "${selectedFilter.displayName}의 핫딜이 없습니다" + searchText.isNotBlank() -> "'$searchText'에 대한 검색 결과가 없습니다" + else -> "표시할 핫딜이 없습니다" + } + EmptyState( + title = "결과가 없습니다", + message = message, + 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.labelLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + if (selectedSiteFilter != null) { + val filter = selectedSiteFilter!! + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "• ${filter.displayName}", + style = MaterialTheme.typography.labelMedium, + color = getSiteColor(filter) + ) + } + } + } + + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + items( + items = filteredDeals, + key = { it.id } + ) { deal -> + AnimatedVisibility( + visible = true, + enter = fadeIn(animationSpec = tween(300)) + + slideInVertically( + animationSpec = tween(300), + initialOffsetY = { it / 8 } + ), + exit = fadeOut(animationSpec = tween(200)) + ) { + DealItem( + deal = deal, + onClick = { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deal.url)) + context.startActivity(intent) + }, + onFavoriteToggle = { dealId -> + viewModel.toggleFavorite(dealId) + } + ) + } + } + + // EdgeToEdge: 하단 네비게이션 바 공간 확보 + item { + Spacer(modifier = Modifier.height(32.dp)) + } + } + } + } + + is MainUiState.Error -> { + ErrorState( + message = state.message, + onRetry = { viewModel.refresh() } + ) } } } - // 검색창 - OutlinedTextField( - value = searchText, - onValueChange = { searchText = it }, - placeholder = { - Text( - text = "제목으로 검색...", - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - singleLine = true, - 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) - ) + // Pull to Refresh 인디케이터 + PullToRefreshContainer( + state = pullToRefreshState, + modifier = Modifier.align(Alignment.TopCenter), + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = MaterialTheme.colorScheme.onPrimaryContainer ) - - // 딜 리스트 - when (val state = uiState) { - is MainUiState.Loading -> { - DealListSkeleton(count = 5) - } - - is MainUiState.Success -> { - val filteredDeals = remember(state.deals, searchText, selectedSiteFilter, showFavoritesOnly, showKeywordMatchOnly) { - state.deals.filter { deal -> - val matchesSearch = searchText.isBlank() || - deal.title.contains(searchText, ignoreCase = true) - val matchesSite = selectedSiteFilter == null || - deal.siteType == selectedSiteFilter - val matchesFavorite = !showFavoritesOnly || deal.isFavorite - val matchesKeyword = !showKeywordMatchOnly || deal.isKeywordMatch - matchesSearch && matchesSite && matchesFavorite && matchesKeyword - } - } - - if (filteredDeals.isEmpty()) { - if (state.deals.isEmpty()) { - NoDealsState(onRefresh = { viewModel.refresh() }) - } else { - val selectedFilter = selectedSiteFilter - val message = when { - showKeywordMatchOnly -> "키워드 매칭된 핫딜이 없습니다" - showFavoritesOnly -> "즐겨찾기한 핫딜이 없습니다" - selectedFilter != null -> "${selectedFilter.displayName}의 핫딜이 없습니다" - searchText.isNotBlank() -> "'$searchText'에 대한 검색 결과가 없습니다" - else -> "표시할 핫딜이 없습니다" - } - EmptyState( - title = "결과가 없습니다", - message = message, - 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.labelLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - if (selectedSiteFilter != null) { - val filter = selectedSiteFilter!! - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = "• ${filter.displayName}", - style = MaterialTheme.typography.labelMedium, - color = getSiteColor(filter) - ) - } - } - } - - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - items( - items = filteredDeals, - key = { it.id } - ) { deal -> - AnimatedVisibility( - visible = true, - enter = fadeIn(animationSpec = tween(300)) + - slideInVertically( - animationSpec = tween(300), - initialOffsetY = { it / 8 } - ), - exit = fadeOut(animationSpec = tween(200)) - ) { - DealItem( - deal = deal, - onClick = { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deal.url)) - context.startActivity(intent) - }, - onFavoriteToggle = { dealId -> - viewModel.toggleFavorite(dealId) - } - ) - } - } - - item { - Spacer(modifier = Modifier.height(16.dp)) - } - } - } - } - - is MainUiState.Error -> { - ErrorState( - message = state.message, - onRetry = { viewModel.refresh() } - ) + + // 새로고침 트리거 + LaunchedEffect(pullToRefreshState.isRefreshing) { + if (pullToRefreshState.isRefreshing) { + isRefreshing = true + viewModel.refresh() } } } diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt b/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt index 9d4b36a..554e08e 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt @@ -9,11 +9,8 @@ import androidx.activity.enableEdgeToEdge import androidx.compose.foundation.layout.* import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.dp -import androidx.compose.material3.* -import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.lifecycleScope import com.hotdeal.alarm.ui.theme.HotDealTheme diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 9f147fd..b7adeca 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -1,11 +1,16 @@ -