v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign

This commit is contained in:
2026-08-20 23:28:58 +00:00
parent b070d317c5
commit 7c311d7d3f
@@ -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<SiteType?>(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))
}
}
}
@@ -424,7 +409,7 @@ fun DealListScreen(
}
}
// Pull to Refresh 인디케이터 - 절대 위치로 배치하여 간격 벌어짐 방지
// One UI 9 Pull to Refresh 인디케이터
val progress = pullToRefreshState.progress
val showIndicator = pullToRefreshState.isRefreshing || progress > 0
@@ -433,14 +418,13 @@ fun DealListScreen(
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
)
}
}