From eee4617989ff4238041254bc0aa179404ff5ec23 Mon Sep 17 00:00:00 2001 From: sanjeok77 Date: Thu, 5 Mar 2026 10:52:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20EdgeToEdge=20=EA=B0=9C=EC=84=A0,=20Pull?= =?UTF-8?q?=20to=20Refresh=20=EC=B6=94=EA=B0=80,=20=EC=B6=9C=EC=B2=98?= =?UTF-8?q?=EB=A5=BC=20=EC=95=8C=20=EC=88=98=20=EC=97=86=EB=8A=94=20?= =?UTF-8?q?=EC=95=B1=20=EC=84=A4=EC=B9=98=20=EA=B6=8C=ED=95=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(v1.6.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - EdgeToEdge: 투명 상태바/네비게이션바 설정, WindowInsets 처리 개선 - Pull to Refresh: 아래로 당겨서 새로고침 기능 추가 - 권한: REQUEST_INSTALL_PACKAGES 추가로 APK 업데이트 설치 지원 - 버전: 1.5.0 -> 1.6.0 (versionCode 11 -> 12) --- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 3 + .../presentation/deallist/DealListScreen.kt | 491 ++++++++++-------- .../alarm/presentation/main/MainActivity.kt | 5 +- app/src/main/res/values/themes.xml | 17 +- 5 files changed, 281 insertions(+), 239 deletions(-) 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 @@ -