feat: UI/UX 개선 및 자동 새로고침 기능 추가

- Edge-to-edge UI 적용 (상태바/네비게이션바 투명)
- WindowInsets 처리로 시스템 바 가림 해결
- 내 키워드 필터 추가 (키워드 매칭된 게시물만 표시)
- 백그라운드에서 포어그라운드 전환 시 자동 새로고침
- Scaffold contentWindowInsets 적용

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
sanjeok77
2026-03-05 07:56:25 +09:00
parent 426393ba86
commit 447db0a0b7
5 changed files with 342 additions and 290 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 = 10 versionCode = 11
versionName = "1.4.1" versionName = "1.5.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@@ -40,290 +40,321 @@ fun DealListScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
var searchText by remember { mutableStateOf("") } var searchText by remember { mutableStateOf("") }
var selectedSiteFilter by remember { mutableStateOf<SiteType?>(null) } var selectedSiteFilter by remember { mutableStateOf<SiteType?>(null) }
var showFavoritesOnly by remember { mutableStateOf(false) } var showFavoritesOnly by remember { mutableStateOf(false) }
var showFilterMenu by remember { mutableStateOf(false) } var showKeywordMatchOnly by remember { mutableStateOf(false) }
var showFilterMenu by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) { Scaffold(
TopAppBar( topBar = {
title = { TopAppBar(
Row(verticalAlignment = Alignment.CenterVertically) { title = {
Box( Row(verticalAlignment = Alignment.CenterVertically) {
modifier = Modifier Box(
.size(36.dp) modifier = Modifier
.background(MaterialTheme.colorScheme.primary, CircleShape), .size(36.dp)
contentAlignment = Alignment.Center .background(MaterialTheme.colorScheme.primary, CircleShape),
) { contentAlignment = Alignment.Center
Icon( ) {
imageVector = Icons.Filled.Notifications, Icon(
contentDescription = null, imageVector = Icons.Filled.Notifications,
tint = MaterialTheme.colorScheme.onPrimary, contentDescription = null,
modifier = Modifier.size(20.dp) tint = MaterialTheme.colorScheme.onPrimary,
) modifier = Modifier.size(20.dp)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "핫딜 알람",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
},
actions = {
BadgedBox(
badge = {
if (selectedSiteFilter != null) {
Badge(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
) )
} }
} Spacer(modifier = Modifier.width(12.dp))
) {
IconButton(onClick = { showFilterMenu = !showFilterMenu }) {
Icon(
imageVector = if (showFilterMenu) Icons.Filled.FilterList else Icons.Outlined.FilterList,
contentDescription = "필터"
)
}
}
IconButton(onClick = { viewModel.refresh() }) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = "새로고침"
)
}
IconButton(onClick = { onNavigateToSettings() }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "설정"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface
)
)
AnimatedVisibility(
visible = showFilterMenu,
enter = expandVertically(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(
text = "사이트 필터", text = "핫딜 알람",
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold
) )
} }
},
Spacer(modifier = Modifier.height(12.dp)) actions = {
BadgedBox(
Row( badge = {
modifier = Modifier if (selectedSiteFilter != null || showFavoritesOnly || showKeywordMatchOnly) {
.fillMaxWidth() Badge(
.horizontalScroll(rememberScrollState()), containerColor = MaterialTheme.colorScheme.primary,
horizontalArrangement = Arrangement.spacedBy(8.dp) contentColor = MaterialTheme.colorScheme.onPrimary
) {
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
)
}
// 즐겨찾기 필터
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) {
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
matchesSearch && matchesSite && matchesFavorite
}
}
if (filteredDeals.isEmpty()) {
if (state.deals.isEmpty()) {
NoDealsState(onRefresh = { viewModel.refresh() })
} else {
val selectedFilter = selectedSiteFilter
val message = when {
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( IconButton(onClick = { showFilterMenu = !showFilterMenu }) {
items = filteredDeals, Icon(
key = { it.id } imageVector = if (showFilterMenu) Icons.Filled.FilterList else Icons.Outlined.FilterList,
) { deal -> contentDescription = "필터"
AnimatedVisibility( )
visible = true, }
enter = fadeIn(animationSpec = tween(300)) + }
slideInVertically( IconButton(onClick = { viewModel.refresh() }) {
animationSpec = tween(300), Icon(
initialOffsetY = { it / 8 } imageVector = Icons.Outlined.Refresh,
), contentDescription = "새로고침"
exit = fadeOut(animationSpec = tween(200)) )
) { }
DealItem( IconButton(onClick = { onNavigateToSettings() }) {
deal = deal, Icon(
onClick = { imageVector = Icons.Outlined.Settings,
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(deal.url)) contentDescription = "설정"
context.startActivity(intent) )
}, }
onFavoriteToggle = { dealId -> },
viewModel.toggleFavorite(dealId) colors = TopAppBarDefaults.topAppBarColors(
} containerColor = MaterialTheme.colorScheme.surface
) )
)
},
contentWindowInsets = WindowInsets.systemBars
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(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
)
} }
} }
item { Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(16.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
)
} }
} }
} }
} }
is MainUiState.Error -> { // 검색창
ErrorState( OutlinedTextField(
message = state.message, value = searchText,
onRetry = { viewModel.refresh() } 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)
}
)
}
}
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
is MainUiState.Error -> {
ErrorState(
message = state.message,
onRetry = { viewModel.refresh() }
)
}
} }
} }
} }

View File

@@ -124,13 +124,30 @@ class MainActivity : ComponentActivity() {
} }
} }
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
// 리시버 해제 // 리시버 해제
downloadReceiver?.let { downloadReceiver?.let {
ApkDownloadManager.unregisterDownloadCompleteReceiver(this, it) ApkDownloadManager.unregisterDownloadCompleteReceiver(this, it)
} }
} }
// 비저빌리티 콜백 - 백그라운드에서 포어그라운드 전환 시 새로고침
private var isInBackground = false
override fun onResume() {
super.onResume()
if (isInBackground) {
// 백그라운드에서 돌아왔을 때 새로고침
workerScheduler.executeOnce()
isInBackground = false
}
}
override fun onPause() {
super.onPause()
isInBackground = true
}
/** /**
* 업데이트 체크 * 업데이트 체크

View File

@@ -197,16 +197,19 @@ fun HotDealTheme(
val view = LocalView.current val view = LocalView.current
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as Activity).window
window.statusBarColor = colorScheme.primary.toArgb() // Edge-to-edge: 투명한 상태바
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme window.statusBarColor = android.graphics.Color.TRANSPARENT
} window.navigationBarColor = android.graphics.Color.TRANSPARENT
} WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme
MaterialTheme( }
colorScheme = colorScheme,
typography = AppTypography, MaterialTheme(
content = content colorScheme = colorScheme,
) typography = AppTypography,
content = content
)
}
} }

View File

@@ -1,13 +1,14 @@
{ {
"version": "1.4.1", "version": "1.5.0",
"versionCode": 10, "versionCode": 11,
"minSdk": 31, "minSdk": 31,
"targetSdk": 35, "targetSdk": 35,
"forceUpdate": false, "forceUpdate": false,
"updateUrl": "https://git.webpluss.net/attachments/8571e491-bc80-4773-b6c7-32254adc4117", "updateUrl": "https://git.webpluss.net/attachments/8571e491-bc80-4773-b6c7-32254adc4117",
"changelog": [ "changelog": [
"설정 화면 진입 시 크래시 수정", "Edge-to-edge UI 적용 (상태바/네비게이션바 투명)",
"AppDatabase 누락된 괄호 수정", "내 키워드 필터 추가",
"REQUEST_INSTALL_PACKAGES 권한 예외 처리" "백그라운드에서 포어그라운드 전환 시 자동 새로고침",
"WindowInsets 처리로 시스템 바 가림 해결"
] ]
} }