7 Commits
5 changed files with 270 additions and 256 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ android {
applicationId = "com.hotdeal.alarm"
minSdk = 31
targetSdk = 35
versionCode = 25
versionName = "0.2.5"
versionCode = 27
versionName = "0.2.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -2,7 +2,6 @@ package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
@@ -12,7 +11,6 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
@@ -25,8 +23,8 @@ import com.hotdeal.alarm.ui.theme.*
import com.hotdeal.alarm.util.ShareHelper
/**
* One UI 9 & Material 3 Expressive Deal Item Card
* Text Spotlight 가독성과 Squircle 앰비언트 깊이감을 적용한 프리미엄 핫딜 카드
* One UI 9 & Material 3 Expressive Compact Deal Item Card
* 화면 공간 낭비를 없애고 가독성과 정보 밀도를 극대화한 슬림 핫딜 카드
*/
@Composable
fun DealItem(
@@ -38,35 +36,32 @@ fun DealItem(
val context = LocalContext.current
val siteColor = getSiteColor(deal.siteType)
// 즐겨찾기 하트 펄스 애니메이션
val favoriteScale by animateFloatAsState(
targetValue = if (deal.isFavorite) 1.25f else 1f,
targetValue = if (deal.isFavorite) 1.2f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "favorite_pulse"
label = "fav_scale"
)
// 제목에서 가격 정보 및 쇼핑몰 태그 추출 (Text Spotlight용)
val parsedInfo = remember(deal.title) {
parseDealTitle(deal.title)
}
// 카드 테두리 및 배경 톤 설정 (One UI 9 Squircle Depth)
val (cardBorderColor, cardBorderWidth) = when {
deal.isKeywordMatch -> KeywordGold to 1.5.dp
deal.isPopular -> HotDealFlameColor.copy(alpha = 0.8f) to 1.2.dp
else -> MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) to 0.8.dp
deal.isKeywordMatch -> KeywordGold to 1.2.dp
deal.isPopular -> HotDealFlameColor.copy(alpha = 0.75f) to 1.dp
else -> MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f) to 0.8.dp
}
val cardBgColor = when {
deal.isKeywordMatch -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
deal.isKeywordMatch -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.surface
}
SquircleCard(
shape = CornerRadius.shapeSquircle,
shape = CornerRadius.shapeNormal,
containerColor = cardBgColor,
borderColor = cardBorderColor,
borderWidth = cardBorderWidth,
@@ -77,10 +72,10 @@ fun DealItem(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp)
.padding(horizontal = 14.dp, vertical = 9.dp)
) {
// ============================================
// 1. 상단 메타 바: 사이트 뱃지 + 게시판 + 특수 뱃지 + 액션
// 1. 상단 메타 바: 사이트 뱃지 + 게시판 + 쇼핑몰 + 특수 뱃지
// ============================================
Row(
modifier = Modifier.fillMaxWidth(),
@@ -88,107 +83,107 @@ fun DealItem(
) {
// 사이트 캡슐 뱃지
Surface(
shape = CornerRadius.shapeSmall,
shape = CornerRadius.shapeMicro,
color = siteColor.copy(alpha = 0.12f),
modifier = Modifier.height(26.dp)
modifier = Modifier.height(21.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.padding(horizontal = 8.dp)
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 6.dp)
) {
Box(
modifier = Modifier
.size(6.dp)
.size(5.dp)
.background(siteColor, CircleShape)
)
Text(
text = deal.siteType?.displayName ?: deal.siteName,
style = SpotlightTypography.badge,
style = SpotlightTypography.badge.copy(fontSize = 11.sp),
color = siteColor
)
}
}
Spacer(modifier = Modifier.width(6.dp))
Spacer(modifier = Modifier.width(5.dp))
// 게시판 라벨
Text(
text = deal.boardDisplayName,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// 쇼핑몰 태그 (추출된 경우)
// 쇼핑몰 태그
if (parsedInfo.storeTag.isNotEmpty()) {
Spacer(modifier = Modifier.width(6.dp))
Spacer(modifier = Modifier.width(5.dp))
Surface(
shape = CornerRadius.shapeMicro,
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f),
modifier = Modifier.height(20.dp)
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f),
modifier = Modifier.height(19.dp)
) {
Text(
text = parsedInfo.storeTag,
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.5.sp),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp)
)
}
}
Spacer(modifier = Modifier.weight(1f))
// 키워드 매칭 뱃지 (One UI 9 Amber Gold)
// 키워드 매칭 뱃지
if (deal.isKeywordMatch) {
Surface(
shape = CornerRadius.shapeSmall,
shape = CornerRadius.shapeMicro,
color = KeywordGold,
modifier = Modifier.height(24.dp)
modifier = Modifier.height(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 7.dp)
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(horizontal = 6.dp)
) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(11.dp)
modifier = Modifier.size(10.dp)
)
Text(
text = "내 키워드",
style = SpotlightTypography.badge.copy(fontSize = 11.sp),
style = SpotlightTypography.badge.copy(fontSize = 10.5.sp),
color = Color.White
)
}
}
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(3.dp))
}
// 인기 핫딜 뱃지 (One UI 9 Flame Orange)
// 인기 핫딜 뱃지
if (deal.isPopular) {
Surface(
shape = CornerRadius.shapeSmall,
shape = CornerRadius.shapeMicro,
color = HotDealFlameColor,
modifier = Modifier.height(24.dp)
modifier = Modifier.height(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 7.dp)
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(horizontal = 6.dp)
) {
Icon(
imageVector = Icons.Filled.Whatshot,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(11.dp)
modifier = Modifier.size(10.dp)
)
Text(
text = "HOT",
style = SpotlightTypography.badge.copy(fontSize = 11.sp),
style = SpotlightTypography.badge.copy(fontSize = 10.5.sp),
color = Color.White
)
}
@@ -196,84 +191,82 @@ fun DealItem(
}
}
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = Modifier.height(6.dp))
// ============================================
// 2. 제목 (Clean Spotlight Typography)
// 2. 본문 제목 (Slim 2-Line Spotlight)
// ============================================
Text(
text = parsedInfo.cleanTitle,
style = MaterialTheme.typography.titleMedium.copy(
style = MaterialTheme.typography.titleSmall.copy(
fontSize = 14.5.sp,
fontWeight = if (deal.isKeywordMatch) FontWeight.Bold else FontWeight.SemiBold,
lineHeight = 22.sp
lineHeight = 19.sp
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(6.dp))
// ============================================
// 3. 하단 바: 가격(Text Spotlight) + 시간 + 액션 버튼
// 3. 하단 바: 가격(Spotlight) + 시간 + 액션 아이콘
// ============================================
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 가격이 추출되었을 경우 볼드 강조, 없을 경우 기본 시간 강조
if (parsedInfo.priceText.isNotEmpty()) {
Text(
text = parsedInfo.priceText,
style = SpotlightTypography.priceLarge,
style = SpotlightTypography.priceLarge.copy(fontSize = 16.sp),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(6.dp))
}
// 작성 상대 시간
Text(
text = formatRelativeTime(deal.createdAt),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f)
)
Spacer(modifier = Modifier.weight(1f))
// 액션 아이콘들 (공유 & 즐겨찾기)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
IconButton(
onClick = { ShareHelper.shareDeal(context, deal) },
modifier = Modifier.size(32.dp)
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = "공유",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
modifier = Modifier.size(17.dp)
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.size(15.dp)
)
}
IconButton(
onClick = { onFavoriteToggle(deal.id) },
modifier = Modifier
.size(32.dp)
.size(28.dp)
.scale(favoriteScale)
) {
Icon(
imageVector = if (deal.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (deal.isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가",
tint = if (deal.isFavorite) FavoriteColor else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
modifier = Modifier.size(18.dp)
tint = if (deal.isFavorite) FavoriteColor else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.size(16.dp)
)
}
}
@@ -3,7 +3,6 @@ package com.hotdeal.alarm.presentation.deallist
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
@@ -12,6 +11,8 @@ 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.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
@@ -22,10 +23,14 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
@@ -38,8 +43,8 @@ import com.hotdeal.alarm.ui.theme.*
import kotlinx.coroutines.launch
/**
* One UI 9 & Material 3 Expressive Deal Feed Screen
* 실시간 Now 브리핑, 일체형 검색 & Sticky Pill 필터링을 지원하는 메인 핫딜 피드
* One UI 9 & Material 3 Expressive Compact Deal Feed Screen
* 온디맨드 검색(In-AppBar Search)과 스크롤 반응형 상단바로 화면 시야 및 컨텐츠 노출 수를 극대화한 화면
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -49,11 +54,13 @@ fun DealListScreen(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val focusManager = LocalFocusManager.current
// Pull to Refresh (초고감도 설정)
val pullToRefreshState = rememberPullToRefreshState(
positionalThreshold = 40.dp
)
// 스크롤 시 상단바 자동 축소/숨김 동작
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
// Pull to Refresh
val pullToRefreshState = rememberPullToRefreshState(positionalThreshold = 40.dp)
var isRefreshing by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
@@ -69,67 +76,129 @@ fun DealListScreen(
}
}
// 필터 상태
// 검색 모드 및 필터 상태
var isSearchActive by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
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) }
LaunchedEffect(isSearchActive) {
if (isSearchActive) {
searchFocusRequester.requestFocus()
}
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
// One UI 9 App Logo Icon
Box(
if (isSearchActive) {
// 검색 모드일 때의 인라인 검색 텍스트 필드
TextField(
value = searchText,
onValueChange = { searchText = it },
placeholder = {
Text(
text = "키워드, 브랜드, 상품명 검색...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
textStyle = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.size(36.dp)
.clip(CornerRadius.shapeSmall)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
.fillMaxWidth()
.focusRequester(searchFocusRequester)
)
} else {
// 기본 상단 타이틀
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
imageVector = Icons.Filled.LocalFireDepartment,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp)
)
}
Box(
modifier = Modifier
.size(32.dp)
.clip(CornerRadius.shapeMicro)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.LocalFireDepartment,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(18.dp)
)
}
Column {
Text(
text = "핫딜 알람",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.3).sp
letterSpacing = (-0.2).sp
)
}
}
},
navigationIcon = {
if (isSearchActive) {
IconButton(onClick = {
isSearchActive = false
searchText = ""
}) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "검색 닫기",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
},
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))
}
if (isSearchActive) {
if (searchText.isNotEmpty()) {
IconButton(onClick = { searchText = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "검색어 지우기",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
// 1. 검색 버튼 (누르면 검색 모드로 전환)
IconButton(onClick = { isSearchActive = true }) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = "검색",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { viewModel.refresh() }) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = "새로고침",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
// 2. 새로고침 버튼
IconButton(onClick = { viewModel.refresh() }) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = "새로고침",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
scrolledContainerColor = MaterialTheme.colorScheme.surface
@@ -150,11 +219,12 @@ fun DealListScreen(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = CircleShape,
modifier = Modifier.navigationBarsPadding()
modifier = Modifier.size(44.dp).navigationBarsPadding()
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = "맨 위로"
contentDescription = "맨 위로",
modifier = Modifier.size(22.dp)
)
}
}
@@ -173,64 +243,18 @@ fun DealListScreen(
.consumeWindowInsets(paddingValues)
) {
// ============================================
// 1. One UI 9 Seamless Search Field
// ============================================
OutlinedTextField(
value = searchText,
onValueChange = { searchText = it },
placeholder = {
Text(
text = "키워드, 브랜드, 상품명 검색...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
singleLine = true,
shape = CornerRadius.shapeNormal,
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = "검색",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
)
},
trailingIcon = {
if (searchText.isNotEmpty()) {
IconButton(onClick = { searchText = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "지우기",
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.outlineVariant.copy(alpha = 0.35f)
)
)
// ============================================
// 2. One UI 9 Sticky Horizontal Pill Filter Bar
// 슬림 가로 스크롤 Sticky Pill Filter Bar
// ============================================
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.padding(vertical = 4.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Spacer(modifier = Modifier.width(8.dp))
// "전체" 필터 칩
FilterPillChip(
selected = selectedSiteFilter == null && !showFavoritesOnly && !showPopularOnly && !showKeywordMatchOnly,
onClick = {
@@ -243,7 +267,6 @@ fun DealListScreen(
accentColor = MaterialTheme.colorScheme.primary
)
// "🔥 HOT 인기" 필터 칩
FilterPillChip(
selected = showPopularOnly,
onClick = {
@@ -257,7 +280,6 @@ fun DealListScreen(
accentColor = HotDealFlameColor
)
// "⭐ 내 키워드" 필터 칩
FilterPillChip(
selected = showKeywordMatchOnly,
onClick = {
@@ -271,7 +293,6 @@ fun DealListScreen(
accentColor = KeywordGold
)
// "❤️ 즐겨찾기" 필터 칩
FilterPillChip(
selected = showFavoritesOnly,
onClick = {
@@ -285,7 +306,6 @@ fun DealListScreen(
accentColor = FavoriteColor
)
// 사이트별 개별 필터 칩들 (뽐뿌, 클리앙, 루리웹, 쿨엔조이, 아카라이브)
SiteType.entries.forEach { siteType ->
val siteColor = getSiteColor(siteType)
FilterPillChip(
@@ -302,7 +322,7 @@ fun DealListScreen(
}
// ============================================
// 3. 핫딜 목록 본문 (List Content)
// 핫딜 목록 본문 (List Content)
// ============================================
when (val state = uiState) {
is MainUiState.Loading -> {
@@ -347,35 +367,11 @@ fun DealListScreen(
)
}
} else {
// 리스트 상단 카운터 바
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
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 = "${selectedSiteFilter?.displayName}",
style = MaterialTheme.typography.labelSmall,
color = getSiteColor(selectedSiteFilter)
)
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(10.dp)
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(7.dp)
) {
items(
items = filteredDeals,
@@ -394,7 +390,7 @@ fun DealListScreen(
}
item {
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@@ -409,7 +405,7 @@ fun DealListScreen(
}
}
// One UI 9 Pull to Refresh 인디케이터
// Pull to Refresh 인디케이터
val progress = pullToRefreshState.progress
val showIndicator = pullToRefreshState.isRefreshing || progress > 0
@@ -418,7 +414,7 @@ fun DealListScreen(
state = pullToRefreshState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 90.dp)
.padding(top = 50.dp)
.zIndex(999f),
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.primary
@@ -436,7 +432,7 @@ fun DealListScreen(
}
/**
* One UI 9 Capsule Pill Filter Chip
* One UI 9 Compact Capsule Pill Filter Chip
*/
@Composable
private fun FilterPillChip(
@@ -446,7 +442,7 @@ private fun FilterPillChip(
accentColor: Color
) {
val bgColor = if (selected) {
accentColor.copy(alpha = 0.16f)
accentColor.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surface
}
@@ -454,13 +450,13 @@ private fun FilterPillChip(
val textColor = if (selected) {
accentColor
} else {
MaterialTheme.colorScheme.onSurfaceVariant
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
}
val borderColor = if (selected) {
accentColor.copy(alpha = 0.6f)
accentColor.copy(alpha = 0.55f)
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
}
Surface(
@@ -468,17 +464,18 @@ private fun FilterPillChip(
color = bgColor,
border = androidx.compose.foundation.BorderStroke(0.8.dp, borderColor),
modifier = Modifier
.height(34.dp)
.height(29.dp)
.elasticPressClickable(onClick = onClick)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp)
modifier = Modifier.padding(horizontal = 11.dp, vertical = 4.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
fontSize = 11.5.sp
),
color = textColor
)
@@ -11,11 +11,13 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
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
@@ -24,7 +26,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hotdeal.alarm.presentation.components.PermissionDialog
import com.hotdeal.alarm.presentation.components.elasticPressClickable
import com.hotdeal.alarm.presentation.deallist.DealListScreen
@@ -33,13 +34,12 @@ import com.hotdeal.alarm.ui.theme.CornerRadius
import kotlinx.coroutines.launch
/**
* One UI 9 Fluid Navigation Framework
* 한 손 조작성을 극대화한 하단 캡슐 내비게이션 바 및 화면 전환
* One UI 9 Docked Navigation Framework
* 바닥까지 완벽하게 밀착된 일체형 네이티브 하단 바
*/
@Composable
fun MainScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showPermissionDialog by remember { mutableStateOf(false) }
val notificationPermissionLauncher = rememberLauncherForActivityResult(
@@ -67,22 +67,23 @@ fun MainScreen(viewModel: MainViewModel) {
Scaffold(
bottomBar = {
// One UI 9 Fluid Bottom Navigation Bar
Surface(
// One UI 9 Docked Bottom Navigation Bar (바닥과 완벽 일체화)
Column(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
border = androidx.compose.foundation.BorderStroke(
0.8.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f)
)
.background(MaterialTheme.colorScheme.surface)
) {
NavigationBar(
containerColor = Color.Transparent,
tonalElevation = 0.dp,
modifier = Modifier.height(64.dp)
// 상단 초미세 헤어라인 구분선
HorizontalDivider(
thickness = 0.6.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
)
Row(
modifier = Modifier
.fillMaxWidth()
.height(54.dp),
verticalAlignment = Alignment.CenterVertically
) {
val navItems = listOf(
Triple(0, "핫딜 피드", Icons.Filled.LocalFireDepartment to Icons.Outlined.LocalFireDepartment),
@@ -93,39 +94,65 @@ fun MainScreen(viewModel: MainViewModel) {
val isSelected = pagerState.currentPage == pageIndex
val (selectedIcon, unselectedIcon) = icons
NavigationBarItem(
selected = isSelected,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pageIndex)
val itemColor = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.elasticPressClickable {
coroutineScope.launch {
pagerState.animateScrollToPage(pageIndex)
}
},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 선택 시 상단 미니 캡슐 인디케이터
if (isSelected) {
Box(
modifier = Modifier
.width(20.dp)
.height(3.dp)
.clip(CornerRadius.shapePill)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.height(3.dp))
} else {
Spacer(modifier = Modifier.height(6.dp))
}
},
icon = {
Icon(
imageVector = if (isSelected) selectedIcon else unselectedIcon,
contentDescription = label,
modifier = Modifier.size(24.dp)
tint = itemColor,
modifier = Modifier.size(22.dp)
)
},
label = {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
fontSize = 11.sp
)
fontSize = 11.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
),
color = itemColor
)
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
)
)
}
}
}
}
// 시스템 제스처 네비게이션 바 영역 패딩 (배경색은 일체형으로 유지)
Spacer(modifier = Modifier.navigationBarsPadding())
}
},
contentWindowInsets = WindowInsets(0, 0, 0, 0)
+6 -9
View File
@@ -1,13 +1,10 @@
{
"version": "0.2.5",
"versionCode": 25,
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases/download/v0.2.5/app-release.apk",
"version": "0.2.7",
"versionCode": 27,
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases/download/v0.2.7/app-release.apk",
"changelog": [
"One UI 9 & Material 3 Expressive 프리미엄 디자인 전면 적용",
"실시간 Now Status 브리핑 & 가로 스크롤 Sticky Pill 필터 바",
"Squircle 22dp 핫딜 카드, Text Spotlight 가격 강조, 앰버 골드 키워드 글로우",
"Bento 그리드 설정 화면 (세그먼트 Pill 탭, 상태 요약 카드, 원터치 주기 선택)",
"한 손 조작 최적화 하단 Fluid Navigation Bar 및 OLED Deep Slate 테마",
"아카라이브(ArcaLive) 핫딜 채널 추가"
"하단 고정 메뉴 일체형(Docked) 디자인 개선 (떠 있는 이질감 완전 제거)",
"시스템 내비게이션 바닥까지 끊김 없는 배경 처리 및 0.6dp 헤어라인 구분선",
"상단 미니 캡슐 인디케이터와 One UI 9 네이티브 탭 전환 감각 적용"
]
}