From 378a8c09a85c091e09966a63638eb7d459b0a468 Mon Sep 17 00:00:00 2001 From: sanjeok77 Date: Sat, 7 Mar 2026 02:53:52 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20UI/UX=20=EA=B0=9C=EC=84=A0=20-=20?= =?UTF-8?q?=ED=82=A4=EC=9B=8C=EB=93=9C=20=EC=B9=B4=EB=93=9C=20=EB=94=94?= =?UTF-8?q?=EC=9E=90=EC=9D=B8=20=ED=86=B5=EC=9D=BC=20=EB=B0=8F=20Pull=20to?= =?UTF-8?q?=20Refresh=20=EA=B0=9C=EC=84=A0=20(v1.7.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 키워드 카드 디자인을 DealItem과 동일한 스타일로 통일 - 활성화된 키워드 카드에 옅은 빨간색 배경 적용 - Pull to Refresh 인디케이터 z-index 높여서 잘림 문제 해결 - Top 버튼 위치 조정으로 메뉴키 가림 문제 해결 - Pull to Refresh 감도 향상 (threshold 120dp → 60dp) --- app/build.gradle.kts | 6 +- .../alarm/presentation/components/DealItem.kt | 349 ++++++++---------- .../presentation/deallist/DealListScreen.kt | 45 ++- .../presentation/settings/SettingsScreen.kt | 122 +++--- 4 files changed, 277 insertions(+), 245 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 458ef7c..2f77243 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,7 +24,8 @@ android { applicationId = "com.hotdeal.alarm" minSdk = 31 targetSdk = 35 - versionCode = 12 + versionCode = 13 + versionName = "1.7.0" versionName = "1.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -67,7 +68,8 @@ android { freeCompilerArgs += listOf( "-opt-in=androidx.compose.material3.ExperimentalMaterial3Api", "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi", - "-opt-in=kotlinx.coroutines.FlowPreview" + "-opt-in=kotlinx.coroutines.FlowPreview", + "-opt-in=androidx.compose.foundation.ExperimentalFoundationApi" ) } diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/components/DealItem.kt b/app/src/main/java/com/hotdeal/alarm/presentation/components/DealItem.kt index c23eff4..ec6a0d5 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/components/DealItem.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/components/DealItem.kt @@ -30,28 +30,28 @@ import com.hotdeal.alarm.util.ShareHelper @OptIn(ExperimentalMaterial3Api::class) @Composable fun DealItem( - deal: HotDeal, - onClick: () -> Unit, - onFavoriteToggle: (String) -> Unit = {}, - modifier: Modifier = Modifier + deal: HotDeal, + onClick: () -> Unit, + onFavoriteToggle: (String) -> Unit = {}, + modifier: Modifier = Modifier ) { - val context = LocalContext.current + val context = LocalContext.current - val favoriteScale by animateFloatAsState( - targetValue = if (deal.isFavorite) 1.2f else 1f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow - ), - label = "favorite_scale" - ) + val favoriteScale by animateFloatAsState( + targetValue = if (deal.isFavorite) 1.2f else 1f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow + ), + label = "favorite_scale" + ) val siteColor = getSiteColor(deal.siteType) - // 키워드 매칭 강조 + // 키워드 매칭 강조 - 옅은 빨간색 배경으로 단순화 val cardColors = if (deal.isKeywordMatch) { CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + containerColor = Color(0xFFFFEBEE) // 옅은 빨간색 배경 (Material Red 50) ) } else { CardDefaults.elevatedCardColors( @@ -64,215 +64,178 @@ fun DealItem( shape = RoundedCornerShape(20.dp), colors = cardColors, elevation = CardDefaults.elevatedCardElevation( - defaultElevation = if (deal.isKeywordMatch) 6.dp else 2.dp + defaultElevation = 2.dp ), onClick = onClick ) { - Box( + Column( modifier = Modifier .fillMaxWidth() - .then( - if (deal.isKeywordMatch) { - Modifier.background( - brush = Brush.horizontalGradient( - colors = listOf( - MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), - MaterialTheme.colorScheme.primary.copy(alpha = 0.05f) - ) - ) - ) - } else { - Modifier - } - ) + .padding(16.dp) ) { - // 키워드 매칭 인디케이터 (왼쪽) - if (deal.isKeywordMatch) { - Box( - modifier = Modifier - .align(Alignment.CenterStart) - .width(4.dp) - .fillMaxHeight() - .background(MaterialTheme.colorScheme.primary) - ) - } - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp) - .then( - if (deal.isKeywordMatch) { - Modifier.padding(start = 8.dp) - } else { - Modifier - } - ) + // 상단: 사이트 뱃지 + 게시판 + 액션 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically ) { - // 상단: 사이트 뱃지 + 게시판 + 액션 - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + // 사이트 뱃지 (개선된 디자인) + Surface( + shape = RoundedCornerShape(10.dp), + color = siteColor.copy(alpha = 0.12f), + modifier = Modifier.height(28.dp) ) { - // 사이트 뱃지 (개선된 디자인) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 10.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .background(siteColor, CircleShape) + ) + Text( + text = deal.siteType?.displayName ?: deal.siteName, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = siteColor + ) + } + } + + Spacer(modifier = Modifier.width(8.dp)) + + // 게시판 이름 + Surface( + shape = RoundedCornerShape(8.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.height(24.dp) + ) { + Text( + text = deal.boardDisplayName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + + Spacer(modifier = Modifier.weight(1f)) + + // 키워드 매칭 배지 - 빨간색으로 변경 + if (deal.isKeywordMatch) { Surface( - shape = RoundedCornerShape(10.dp), - color = siteColor.copy(alpha = 0.12f), - modifier = Modifier.height(28.dp) + shape = RoundedCornerShape(8.dp), + color = Color(0xFFE53935), // 빨간색 + modifier = Modifier.height(24.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - modifier = Modifier.padding(horizontal = 10.dp) - ) { - Box( - modifier = Modifier - .size(8.dp) - .background(siteColor, CircleShape) - ) - Text( - text = deal.siteType?.displayName ?: deal.siteName, - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Medium, - color = siteColor - ) - } - } - - Spacer(modifier = Modifier.width(8.dp)) - - // 게시판 이름 - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - modifier = Modifier.height(24.dp) - ) { - Text( - text = deal.boardDisplayName, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp) - ) - } - - Spacer(modifier = Modifier.weight(1f)) - - // 키워드 매칭 배지 - if (deal.isKeywordMatch) { - Surface( - shape = RoundedCornerShape(8.dp), - color = MaterialTheme.colorScheme.primary, - modifier = Modifier.height(24.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = 8.dp) - ) { - Icon( - imageVector = Icons.Filled.Star, - contentDescription = null, - tint = Color.White, - modifier = Modifier.size(12.dp) - ) - Text( - text = "내 키워드", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium, - color = Color.White - ) - } - } - Spacer(modifier = Modifier.width(4.dp)) - } - - // 액션 버튼들 - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - // 공유 버튼 - IconButton( - onClick = { ShareHelper.shareDeal(context, deal) }, - modifier = Modifier.size(36.dp) + horizontalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.padding(horizontal = 8.dp) ) { Icon( - imageVector = Icons.Outlined.Share, - contentDescription = "공유", - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) + imageVector = Icons.Filled.Star, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(12.dp) + ) + Text( + text = "내 키워드", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = Color.White ) } - - // 즐겨찾기 버튼 - IconButton( - onClick = { onFavoriteToggle(deal.id) }, - modifier = Modifier - .size(36.dp) - .scale(favoriteScale) - ) { - Icon( - imageVector = if (deal.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, - contentDescription = if (deal.isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가", - tint = if (deal.isFavorite) - MaterialTheme.colorScheme.error - else - MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(18.dp) - ) - } } + Spacer(modifier = Modifier.width(4.dp)) } - Spacer(modifier = Modifier.height(12.dp)) - - // 제목 - Text( - text = deal.title, - style = if (deal.isKeywordMatch) { - MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) - } else { - MaterialTheme.typography.bodyLarge - }, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - color = MaterialTheme.colorScheme.onSurface - ) - - Spacer(modifier = Modifier.height(10.dp)) - - // 하단: 시간 + 추가 정보 + // 액션 버튼들 Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - // 시간 - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp) + // 공유 버튼 + IconButton( + onClick = { ShareHelper.shareDeal(context, deal) }, + modifier = Modifier.size(36.dp) ) { Icon( - imageVector = Icons.Outlined.Schedule, - contentDescription = null, + imageVector = Icons.Outlined.Share, + contentDescription = "공유", tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp) - ) - Text( - text = formatTime(deal.createdAt), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + modifier = Modifier.size(18.dp) ) } - Spacer(modifier = Modifier.weight(1f)) + // 즐겨찾기 버튼 + IconButton( + onClick = { onFavoriteToggle(deal.id) }, + modifier = Modifier + .size(36.dp) + .scale(favoriteScale) + ) { + Icon( + imageVector = if (deal.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, + contentDescription = if (deal.isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가", + tint = if (deal.isFavorite) + MaterialTheme.colorScheme.error + else + MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + } + } - // 화살표 (클릭 유도) + Spacer(modifier = Modifier.height(12.dp)) + + // 제목 + Text( + text = deal.title, + style = if (deal.isKeywordMatch) { + MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold) + } else { + MaterialTheme.typography.bodyLarge + }, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + color = MaterialTheme.colorScheme.onSurface + ) + + Spacer(modifier = Modifier.height(10.dp)) + + // 하단: 시간 + 추가 정보 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + // 시간 + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { Icon( - imageVector = Icons.Outlined.ArrowForward, - contentDescription = "이동", - tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), - modifier = Modifier.size(16.dp) + imageVector = Icons.Outlined.Schedule, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(14.dp) + ) + Text( + text = formatTime(deal.createdAt), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant ) } + + Spacer(modifier = Modifier.weight(1f)) + + // 화살표 (클릭 유도) + Icon( + imageVector = Icons.Outlined.ArrowForward, + contentDescription = "이동", + tint = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f), + modifier = Modifier.size(16.dp) + ) } } } 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 3c1ea3a..f73bae2 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 @@ -9,6 +9,7 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn 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 @@ -27,12 +28,14 @@ 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.zIndex 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.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -43,9 +46,18 @@ fun DealListScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val context = LocalContext.current - // Pull to Refresh 상태 - val pullToRefreshState = rememberPullToRefreshState() + // Pull to Refresh 상태 - 감도 증가를 위해 threshold 값 감소 + val pullToRefreshState = rememberPullToRefreshState( + positionalThreshold = 60.dp // 기본값(120.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) { @@ -123,6 +135,28 @@ fun DealListScreen( ) ) }, + floatingActionButton = { + AnimatedVisibility( + visible = showScrollToTop, + enter = fadeIn() + scaleIn(), + exit = fadeOut() + scaleOut() + ) { + FloatingActionButton( + onClick = { + scope.launch { listState.animateScrollToItem(0) } + }, + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + shape = CircleShape, + modifier = Modifier.padding(bottom = 80.dp) // 메뉴키에 가려지지 않도록 상단으로 이동 + ) { + Icon( + imageVector = Icons.Filled.KeyboardArrowUp, + contentDescription = "맨 위로" + ) + } + } + }, contentWindowInsets = WindowInsets(0, 0, 0, 0) ) { paddingValues -> Box( @@ -332,6 +366,7 @@ fun DealListScreen( LazyColumn( modifier = Modifier.fillMaxSize(), + state = listState, contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { @@ -378,10 +413,12 @@ fun DealListScreen( } } - // Pull to Refresh 인디케이터 + // Pull to Refresh 인디케이터 - z-index 높여서 잘림 방지 PullToRefreshContainer( state = pullToRefreshState, - modifier = Modifier.align(Alignment.TopCenter), + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(999f), // 최상위 레이어로 설정 containerColor = MaterialTheme.colorScheme.primaryContainer, contentColor = MaterialTheme.colorScheme.onPrimaryContainer ) diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt index 7df0432..6b19147 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt @@ -736,13 +736,20 @@ private fun EnhancedKeywordCard( onToggle: () -> Unit, onDelete: () -> Unit ) { - Card( + val isEnabled = keyword.isEnabled + + ElevatedCard( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.elevatedCardColors( + containerColor = if (isEnabled) + Color(0xFFFFEBEE) // 옅은 빨간색 배경 (Material Red 50) + else + MaterialTheme.colorScheme.surface ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + elevation = CardDefaults.elevatedCardElevation( + defaultElevation = 2.dp + ) ) { Row( modifier = Modifier @@ -751,21 +758,35 @@ private fun EnhancedKeywordCard( verticalAlignment = Alignment.CenterVertically ) { // 키워드 아이콘 - Box( - modifier = Modifier.size(40.dp) - .background( - Color(0xFFFFCDD2), // 옅은 붉은색 (Light Red) - CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.Tag, - contentDescription = null, - tint = Color(0xFFD32F2F), // 붉은색 아이콘 - modifier = Modifier.size(20.dp) - ) - } + Surface( + shape = RoundedCornerShape(10.dp), + color = if (isEnabled) + Color(0xFFE53935).copy(alpha = 0.12f) // 빨간색 + else + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + modifier = Modifier.height(36.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(horizontal = 10.dp) + ) { + Box( + modifier = Modifier + .size(8.dp) + .background( + if (isEnabled) Color(0xFFE53935) else MaterialTheme.colorScheme.outline, + CircleShape + ) + ) + Text( + text = "키워드", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = if (isEnabled) Color(0xFFE53935) else MaterialTheme.colorScheme.outline + ) + } + } Spacer(modifier = Modifier.width(12.dp)) @@ -774,42 +795,51 @@ private fun EnhancedKeywordCard( Text( text = keyword.keyword, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Medium, + fontWeight = if (isEnabled) FontWeight.Bold else FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface ) Text( - text = if (keyword.isEnabled) "알림 활성화" else "알림 비활성화", + text = if (isEnabled) "알림 활성화" else "알림 비활성화", style = MaterialTheme.typography.bodySmall, - color = if (keyword.isEnabled) - MaterialTheme.colorScheme.primary + color = if (isEnabled) + Color(0xFFE53935) // 빨간색 else MaterialTheme.colorScheme.onSurfaceVariant ) } - // 토글 스위치 - Switch( - checked = keyword.isEnabled, - onCheckedChange = { onToggle() }, - colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.primary, - checkedTrackColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) - ) - ) - - Spacer(modifier = Modifier.width(4.dp)) - - // 삭제 버튼 - IconButton( - onClick = onDelete, - modifier = Modifier.size(40.dp) + // 액션 버튼들 + Row( + horizontalArrangement = Arrangement.spacedBy(2.dp) ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = "삭제", - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(20.dp) - ) + // 토글 버튼 + IconButton( + onClick = onToggle, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = if (isEnabled) Icons.Filled.Notifications else Icons.Outlined.NotificationsNone, + contentDescription = if (isEnabled) "알림 끄기" else "알림 켜기", + tint = if (isEnabled) + Color(0xFFE53935) // 빨간색 + else + MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + } + + // 삭제 버튼 + IconButton( + onClick = onDelete, + modifier = Modifier.size(36.dp) + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "삭제", + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(18.dp) + ) + } } } }