diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/deallist/DealListScreen.kt b/app/src/main/java/com/hotdeal/alarm/presentation/deallist/DealListScreen.kt index ac3bffd..1ea7870 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/deallist/DealListScreen.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/deallist/DealListScreen.kt @@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState 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.* import androidx.compose.material.icons.outlined.* @@ -22,22 +21,26 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.zIndex -import androidx.compose.ui.draw.scale import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hotdeal.alarm.domain.model.SiteType import com.hotdeal.alarm.presentation.components.* import com.hotdeal.alarm.presentation.main.MainUiState import com.hotdeal.alarm.presentation.main.MainViewModel -import com.hotdeal.alarm.ui.theme.getSiteColor -import kotlinx.coroutines.delay +import com.hotdeal.alarm.ui.theme.* import kotlinx.coroutines.launch +/** + * One UI 9 & Material 3 Expressive Deal Feed Screen + * 실시간 Now 브리핑, 일체형 검색 & Sticky Pill 필터링을 지원하는 메인 핫딜 피드 + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun DealListScreen( @@ -47,20 +50,18 @@ fun DealListScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - // Pull to Refresh 상태 - 민감도 대폭 향상 + // Pull to Refresh (초고감도 설정) val pullToRefreshState = rememberPullToRefreshState( - positionalThreshold = 40.dp // 기본값의 1/3로 설정하여 매우 쉽게 새로고침 + positionalThreshold = 40.dp ) var isRefreshing by remember { mutableStateOf(false) } - - // List state for scroll detection + val listState = rememberLazyListState() val scope = rememberCoroutineScope() val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 3 } } - // 새로고침 완료 감지 LaunchedEffect(uiState) { if (uiState !is MainUiState.Loading) { isRefreshing = false @@ -68,79 +69,69 @@ fun DealListScreen( } } + // 필터 상태 var searchText by remember { mutableStateOf("") } var selectedSiteFilter by remember { mutableStateOf(null) } var showFavoritesOnly by remember { mutableStateOf(false) } + var showPopularOnly by remember { mutableStateOf(false) } var showKeywordMatchOnly by remember { mutableStateOf(false) } - var showFilterMenu by remember { mutableStateOf(false) } - - // 필터 메뉴 자동 닫기 (5초) - LaunchedEffect(showFilterMenu) { - if (showFilterMenu) { - delay(5000L) - showFilterMenu = false - } - } Scaffold( topBar = { TopAppBar( title = { - Row(verticalAlignment = Alignment.CenterVertically) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp) + ) { + // One UI 9 App Logo Icon Box( modifier = Modifier .size(36.dp) - .background(MaterialTheme.colorScheme.primary, CircleShape), + .clip(CornerRadius.shapeSmall) + .background(MaterialTheme.colorScheme.primary), contentAlignment = Alignment.Center ) { Icon( - imageVector = Icons.Filled.Notifications, + imageVector = Icons.Filled.LocalFireDepartment, 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 || showFavoritesOnly || showKeywordMatchOnly) { - Badge( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary - ) - } - } - ) { - IconButton(onClick = { showFilterMenu = !showFilterMenu }) { - Icon( - imageVector = if (showFilterMenu) Icons.Filled.FilterList else Icons.Outlined.FilterList, - contentDescription = "필터" + + Column { + Text( + text = "핫딜 알람", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + letterSpacing = (-0.3).sp ) } } + }, + actions = { + // 실시간 상태 요약 캡슐 + if (uiState is MainUiState.Success) { + val activeSitesCount = (uiState as MainUiState.Success).siteConfigs.count { it.isEnabled } + NowStatusPill( + text = if (activeSitesCount > 0) "${activeSitesCount}개 사이트 감시 중" else "모니터링 대기", + isLive = activeSitesCount > 0, + accentColor = MaterialTheme.colorScheme.primary + ) + Spacer(modifier = Modifier.width(4.dp)) + } + IconButton(onClick = { viewModel.refresh() }) { Icon( imageVector = Icons.Outlined.Refresh, - contentDescription = "새로고침" - ) - } - IconButton(onClick = { onNavigateToSettings() }) { - Icon( - imageVector = Icons.Outlined.Settings, - contentDescription = "설정" + contentDescription = "새로고침", + tint = MaterialTheme.colorScheme.onSurfaceVariant ) } }, colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, + containerColor = MaterialTheme.colorScheme.background, scrolledContainerColor = MaterialTheme.colorScheme.surface ), windowInsets = WindowInsets(0, 0, 0, 0) @@ -181,111 +172,30 @@ fun DealListScreen( .padding(paddingValues) .consumeWindowInsets(paddingValues) ) { - // 필터 메뉴 - AnimatedVisibility( - visible = showFilterMenu, - enter = expandVertically(animationSpec = spring(stiffness = Spring.StiffnessLow)) + fadeIn(), - exit = shrinkVertically(animationSpec = spring(stiffness = Spring.StiffnessLow)) + fadeOut() - ) { - 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)) - - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - EnhancedFilterChip( - selected = selectedSiteFilter == null, - onClick = { selectedSiteFilter = null }, - label = "전체", - color = MaterialTheme.colorScheme.primary - ) - - SiteType.entries.forEach { siteType -> - val color = getSiteColor(siteType) - EnhancedFilterChip( - selected = selectedSiteFilter == siteType, - onClick = { - selectedSiteFilter = if (selectedSiteFilter == siteType) null else siteType - }, - label = siteType.displayName, - color = color - ) - } - } - - 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 - ) - } - } - } - } - - // 검색창 + // ============================================ + // 1. One UI 9 Seamless Search Field + // ============================================ OutlinedTextField( value = searchText, onValueChange = { searchText = it }, placeholder = { Text( - text = "제목으로 검색...", - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + text = "키워드, 브랜드, 상품명 검색...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f) ) }, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), + .padding(horizontal = 16.dp, vertical = 6.dp), singleLine = true, - shape = RoundedCornerShape(16.dp), + shape = CornerRadius.shapeNormal, leadingIcon = { Icon( imageVector = Icons.Outlined.Search, contentDescription = "검색", - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + modifier = Modifier.size(20.dp) ) }, trailingIcon = { @@ -294,31 +204,127 @@ fun DealListScreen( Icon( imageVector = Icons.Filled.Close, contentDescription = "지우기", - tint = MaterialTheme.colorScheme.onSurfaceVariant + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) ) } } }, colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f) ) ) - // 딜 리스트 + // ============================================ + // 2. One UI 9 Sticky Horizontal Pill Filter Bar + // ============================================ + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 6.dp) + .horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Spacer(modifier = Modifier.width(8.dp)) + + // "전체" 필터 칩 + FilterPillChip( + selected = selectedSiteFilter == null && !showFavoritesOnly && !showPopularOnly && !showKeywordMatchOnly, + onClick = { + selectedSiteFilter = null + showFavoritesOnly = false + showPopularOnly = false + showKeywordMatchOnly = false + }, + label = "전체", + accentColor = MaterialTheme.colorScheme.primary + ) + + // "🔥 HOT 인기" 필터 칩 + FilterPillChip( + selected = showPopularOnly, + onClick = { + showPopularOnly = !showPopularOnly + if (showPopularOnly) { + showFavoritesOnly = false + showKeywordMatchOnly = false + } + }, + label = "🔥 인기", + accentColor = HotDealFlameColor + ) + + // "⭐ 내 키워드" 필터 칩 + FilterPillChip( + selected = showKeywordMatchOnly, + onClick = { + showKeywordMatchOnly = !showKeywordMatchOnly + if (showKeywordMatchOnly) { + showFavoritesOnly = false + showPopularOnly = false + } + }, + label = "⭐ 내 키워드", + accentColor = KeywordGold + ) + + // "❤️ 즐겨찾기" 필터 칩 + FilterPillChip( + selected = showFavoritesOnly, + onClick = { + showFavoritesOnly = !showFavoritesOnly + if (showFavoritesOnly) { + showPopularOnly = false + showKeywordMatchOnly = false + } + }, + label = "❤️ 보관함", + accentColor = FavoriteColor + ) + + // 사이트별 개별 필터 칩들 (뽐뿌, 클리앙, 루리웹, 쿨엔조이, 아카라이브) + SiteType.entries.forEach { siteType -> + val siteColor = getSiteColor(siteType) + FilterPillChip( + selected = selectedSiteFilter == siteType, + onClick = { + selectedSiteFilter = if (selectedSiteFilter == siteType) null else siteType + }, + label = siteType.displayName, + accentColor = siteColor + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + } + + // ============================================ + // 3. 핫딜 목록 본문 (List Content) + // ============================================ when (val state = uiState) { is MainUiState.Loading -> { - DealListSkeleton(count = 5) + DealListSkeleton(count = 6) } is MainUiState.Success -> { - val filteredDeals = remember(state.deals, searchText, selectedSiteFilter, showFavoritesOnly, showKeywordMatchOnly) { + val filteredDeals = remember( + state.deals, + searchText, + selectedSiteFilter, + showFavoritesOnly, + showPopularOnly, + 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 matchesPopular = !showPopularOnly || deal.isPopular val matchesKeyword = !showKeywordMatchOnly || deal.isKeywordMatch - matchesSearch && matchesSite && matchesFavorite && matchesKeyword + matchesSearch && matchesSite && matchesFavorite && matchesPopular && matchesKeyword } } @@ -326,90 +332,69 @@ fun DealListScreen( if (state.deals.isEmpty()) { NoDealsState(onRefresh = { viewModel.refresh() }) } else { - val selectedFilter = selectedSiteFilter val message = when { showKeywordMatchOnly -> "키워드 매칭된 핫딜이 없습니다" + showPopularOnly -> "인기 핫딜이 없습니다" showFavoritesOnly -> "즐겨찾기한 핫딜이 없습니다" - selectedFilter != null -> "${selectedFilter.displayName}의 핫딜이 없습니다" + selectedSiteFilter != null -> "${selectedSiteFilter?.displayName}의 핫딜이 없습니다" searchText.isNotBlank() -> "'$searchText'에 대한 검색 결과가 없습니다" else -> "표시할 핫딜이 없습니다" } EmptyState( - title = "결과가 없습니다", + 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 + .fillMaxWidth() + .padding(horizontal = 18.dp, vertical = 6.dp), + verticalAlignment = Alignment.CenterVertically ) { - 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) - ) + Text( + text = "총 ${filteredDeals.size}개의 핫딜", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f) + ) + + if (selectedSiteFilter != null) { Spacer(modifier = Modifier.width(6.dp)) Text( - text = "${filteredDeals.size}개의 핫딜", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurfaceVariant + text = "• ${selectedSiteFilter?.displayName}", + style = MaterialTheme.typography.labelSmall, + color = getSiteColor(selectedSiteFilter) ) - 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(), state = listState, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) + contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp), + verticalArrangement = Arrangement.spacedBy(10.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) - } - ) - } + 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(16.dp)) + Spacer(modifier = Modifier.height(24.dp)) } } } @@ -423,24 +408,23 @@ fun DealListScreen( } } } - - // Pull to Refresh 인디케이터 - 절대 위치로 배치하여 간격 벌어짐 방지 + + // One UI 9 Pull to Refresh 인디케이터 val progress = pullToRefreshState.progress val showIndicator = pullToRefreshState.isRefreshing || progress > 0 - + if (showIndicator) { PullToRefreshContainer( state = pullToRefreshState, modifier = Modifier .align(Alignment.TopCenter) - .padding(top = 100.dp) + .padding(top = 90.dp) .zIndex(999f), - containerColor = MaterialTheme.colorScheme.primaryContainer, - contentColor = MaterialTheme.colorScheme.onPrimaryContainer + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = MaterialTheme.colorScheme.primary ) } - - // 새로고침 트리거 + LaunchedEffect(pullToRefreshState.isRefreshing) { if (pullToRefreshState.isRefreshing) { isRefreshing = true @@ -451,46 +435,52 @@ fun DealListScreen( } } +/** + * One UI 9 Capsule Pill Filter Chip + */ @Composable -private fun EnhancedFilterChip( +private fun FilterPillChip( selected: Boolean, onClick: () -> Unit, label: String, - color: Color + accentColor: Color ) { - val scale by animateFloatAsState( - targetValue = if (selected) 1.05f else 1f, - animationSpec = spring(stiffness = Spring.StiffnessLow), - label = "chip_scale" - ) + val bgColor = if (selected) { + accentColor.copy(alpha = 0.16f) + } else { + MaterialTheme.colorScheme.surface + } + + val textColor = if (selected) { + accentColor + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } + + val borderColor = if (selected) { + accentColor.copy(alpha = 0.6f) + } else { + MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f) + } Surface( - onClick = onClick, + shape = CornerRadius.shapePill, + color = bgColor, + border = androidx.compose.foundation.BorderStroke(0.8.dp, borderColor), 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 + .height(34.dp) + .elasticPressClickable(onClick = onClick) ) { - Row( - modifier = Modifier.padding(horizontal = 16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 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 + style = MaterialTheme.typography.labelMedium.copy( + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium + ), + color = textColor ) } }