14 Commits
Author SHA1 Message Date
sanjeok77 0bce4ece00 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:31 +00:00
sanjeok77 a37e07d764 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:29 +00:00
sanjeok77 8f2f5b29d3 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:28 +00:00
sanjeok77 5c272a76e7 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:26 +00:00
sanjeok77 7ae5f713d6 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:24 +00:00
sanjeok77 48e81598b4 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:23 +00:00
sanjeok77 436d494a6d v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:21 +00:00
sanjeok77 3ebd828d8d v0.2.7: Seamless Docked Bottom Navigation Bar Redesign 2026-08-20 23:43:16 +00:00
sanjeok77 93e5562244 v0.2.7: Seamless Docked Bottom Navigation Bar Redesign 2026-08-20 23:43:15 +00:00
sanjeok77 dc07fc6b2b v0.2.7: Seamless Docked Bottom Navigation Bar Redesign 2026-08-20 23:43:13 +00:00
sanjeok77 2602a18d1b v0.2.6: Maximize Main Screen Content Density & In-AppBar Expandable Search 2026-08-20 23:38:52 +00:00
sanjeok77 73c5c727e7 v0.2.6: Maximize Main Screen Content Density & In-AppBar Expandable Search 2026-08-20 23:38:50 +00:00
sanjeok77 34a33426cd v0.2.6: Maximize Main Screen Content Density & In-AppBar Expandable Search 2026-08-20 23:38:49 +00:00
sanjeok77 c1259127f5 v0.2.6: Maximize Main Screen Content Density & In-AppBar Expandable Search 2026-08-20 23:38:47 +00:00
8 changed files with 670 additions and 460 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 = 28
versionName = "0.2.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -5,7 +5,9 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.hotdeal.alarm.domain.model.SiteType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -18,11 +20,13 @@ class AppSettings(private val context: Context) {
companion object {
private val POLLING_INTERVAL_KEY = intPreferencesKey("polling_interval_minutes")
private val SITE_ORDER_KEY = stringPreferencesKey("site_order_list")
private val KEYWORD_ORDER_KEY = stringPreferencesKey("keyword_order_list")
private const val DEFAULT_INTERVAL = 2
}
/**
* 폴 주기 (분)
* 폴 주기 (분)
*/
val pollingInterval: Flow<Int> = context.dataStore.data
.map { preferences ->
@@ -30,11 +34,72 @@ class AppSettings(private val context: Context) {
}
/**
* 폴 주기 설정 저장
* 폴 주기 설정 저장
*/
suspend fun setPollingInterval(minutes: Int) {
context.dataStore.edit { preferences ->
preferences[POLLING_INTERVAL_KEY] = minutes
}
}
/**
* 사이트 표시 순서 목록
*/
val siteOrder: Flow<List<SiteType>> = context.dataStore.data
.map { preferences ->
val savedStr = preferences[SITE_ORDER_KEY]
if (savedStr.isNullOrBlank()) {
SiteType.entries
} else {
val savedNames = savedStr.split(",")
val ordered = mutableListOf<SiteType>()
savedNames.forEach { name ->
try {
ordered.add(SiteType.valueOf(name.trim()))
} catch (e: Exception) {
// ignore invalid
}
}
// 누락된 신규 사이트가 있으면 끝에 추가
SiteType.entries.forEach { site ->
if (site !in ordered) {
ordered.add(site)
}
}
ordered
}
}
/**
* 사이트 표시 순서 저장
*/
suspend fun setSiteOrder(order: List<SiteType>) {
val str = order.joinToString(",") { it.name }
context.dataStore.edit { preferences ->
preferences[SITE_ORDER_KEY] = str
}
}
/**
* 키워드 ID 표시 순서 목록
*/
val keywordOrder: Flow<List<Long>> = context.dataStore.data
.map { preferences ->
val savedStr = preferences[KEYWORD_ORDER_KEY]
if (savedStr.isNullOrBlank()) {
emptyList()
} else {
savedStr.split(",").mapNotNull { it.trim().toLongOrNull() }
}
}
/**
* 키워드 ID 표시 순서 저장
*/
suspend fun setKeywordOrder(order: List<Long>) {
val str = order.joinToString(",") { it.toString() }
context.dataStore.edit { preferences ->
preferences[KEYWORD_ORDER_KEY] = str
}
}
}
@@ -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
@@ -48,12 +53,15 @@ fun DealListScreen(
onNavigateToSettings: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val siteOrder by viewModel.siteOrder.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,26 +77,63 @@ 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 = {
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
.fillMaxWidth()
.focusRequester(searchFocusRequester)
)
} else {
// 기본 상단 타이틀
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// One UI 9 App Logo Icon
Box(
modifier = Modifier
.size(36.dp)
.clip(CornerRadius.shapeSmall)
.size(32.dp)
.clip(CornerRadius.shapeMicro)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) {
@@ -96,32 +141,55 @@ fun DealListScreen(
imageVector = Icons.Filled.LocalFireDepartment,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp)
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
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
)
Spacer(modifier = Modifier.width(4.dp))
}
// 2. 새로고침 버튼
IconButton(onClick = { viewModel.refresh() }) {
Icon(
imageVector = Icons.Outlined.Refresh,
@@ -129,7 +197,9 @@ fun DealListScreen(
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.background,
scrolledContainerColor = MaterialTheme.colorScheme.surface
@@ -150,11 +220,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 +244,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 +268,6 @@ fun DealListScreen(
accentColor = MaterialTheme.colorScheme.primary
)
// "🔥 HOT 인기" 필터 칩
FilterPillChip(
selected = showPopularOnly,
onClick = {
@@ -257,7 +281,6 @@ fun DealListScreen(
accentColor = HotDealFlameColor
)
// "⭐ 내 키워드" 필터 칩
FilterPillChip(
selected = showKeywordMatchOnly,
onClick = {
@@ -271,7 +294,6 @@ fun DealListScreen(
accentColor = KeywordGold
)
// "❤️ 즐겨찾기" 필터 칩
FilterPillChip(
selected = showFavoritesOnly,
onClick = {
@@ -285,8 +307,7 @@ fun DealListScreen(
accentColor = FavoriteColor
)
// 사이트별 개별 필터 칩들 (뽐뿌, 클리앙, 루리웹, 쿨엔조이, 아카라이브)
SiteType.entries.forEach { siteType ->
siteOrder.forEach { siteType ->
val siteColor = getSiteColor(siteType)
FilterPillChip(
selected = selectedSiteFilter == siteType,
@@ -302,7 +323,7 @@ fun DealListScreen(
}
// ============================================
// 3. 핫딜 목록 본문 (List Content)
// 핫딜 목록 본문 (List Content)
// ============================================
when (val state = uiState) {
is MainUiState.Loading -> {
@@ -347,35 +368,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 +391,7 @@ fun DealListScreen(
}
item {
Spacer(modifier = Modifier.height(24.dp))
Spacer(modifier = Modifier.height(16.dp))
}
}
}
@@ -409,7 +406,7 @@ fun DealListScreen(
}
}
// One UI 9 Pull to Refresh 인디케이터
// Pull to Refresh 인디케이터
val progress = pullToRefreshState.progress
val showIndicator = pullToRefreshState.isRefreshing || progress > 0
@@ -418,7 +415,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 +433,7 @@ fun DealListScreen(
}
/**
* One UI 9 Capsule Pill Filter Chip
* One UI 9 Compact Capsule Pill Filter Chip
*/
@Composable
private fun FilterPillChip(
@@ -446,7 +443,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 +451,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 +465,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(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
val navItems = listOf(
Triple(0, "핫딜 피드", Icons.Filled.LocalFireDepartment to Icons.Outlined.LocalFireDepartment),
@@ -93,40 +94,66 @@ fun MainScreen(viewModel: MainViewModel) {
val isSelected = pagerState.currentPage == pageIndex
val (selectedIcon, unselectedIcon) = icons
NavigationBarItem(
selected = isSelected,
onClick = {
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)
}
},
icon = {
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 선택 시 상단 미니 캡슐 인디케이터
if (isSelected) {
Box(
modifier = Modifier
.width(18.dp)
.height(2.5.dp)
.clip(CornerRadius.shapePill)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.height(2.dp))
} else {
Spacer(modifier = Modifier.height(4.5.dp))
}
Icon(
imageVector = if (isSelected) selectedIcon else unselectedIcon,
contentDescription = label,
modifier = Modifier.size(24.dp)
tint = itemColor,
modifier = Modifier.size(20.dp)
)
},
label = {
Spacer(modifier = Modifier.height(1.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
fontSize = 11.sp
)
)
},
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)
)
fontSize = 10.5.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
),
color = itemColor
)
}
}
}
}
// 시스템 제스처 네비게이션 바 영역 패딩 (배경색은 일체형으로 유지)
Spacer(modifier = Modifier.navigationBarsPadding())
}
},
contentWindowInsets = WindowInsets(0, 0, 0, 0)
) { paddingValues ->
@@ -5,15 +5,15 @@ import androidx.lifecycle.viewModelScope
import com.hotdeal.alarm.data.local.db.dao.HotDealDao
import com.hotdeal.alarm.data.local.db.dao.KeywordDao
import com.hotdeal.alarm.data.local.db.dao.SiteConfigDao
import com.hotdeal.alarm.data.local.db.entity.KeywordEntity
import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity
import com.hotdeal.alarm.data.local.preferences.AppSettings
import com.hotdeal.alarm.domain.model.Keyword
import com.hotdeal.alarm.domain.model.SiteType
import com.hotdeal.alarm.worker.WorkerScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
@HiltViewModel
@@ -28,10 +28,17 @@ class MainViewModel @Inject constructor(
private val _uiState = MutableStateFlow<MainUiState>(MainUiState.Loading)
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
// 폴링 주기 (저장된 값 즉시 반영)
// 폴링 주기
val pollingInterval: StateFlow<Int> = appSettings.pollingInterval
.stateIn(viewModelScope, SharingStarted.Eagerly, 2)
// 사이트 표시 순서
val siteOrder: StateFlow<List<SiteType>> = appSettings.siteOrder
.stateIn(viewModelScope, SharingStarted.Eagerly, SiteType.entries)
private val _toastEvent = MutableSharedFlow<String>()
val toastEvent: SharedFlow<String> = _toastEvent.asSharedFlow()
init {
initializeApp()
}
@@ -40,7 +47,6 @@ class MainViewModel @Inject constructor(
viewModelScope.launch {
initializeDefaultSiteConfigs()
loadState()
// 저장된 폴 주기로 시작
val savedInterval = appSettings.pollingInterval.first()
startPolling(savedInterval.toLong())
}
@@ -72,12 +78,22 @@ class MainViewModel @Inject constructor(
combine(
hotDealDao.observeAllDeals(),
siteConfigDao.observeAllConfigs(),
keywordDao.observeAllKeywords()
) { deals, configs, keywords ->
keywordDao.observeAllKeywords(),
appSettings.keywordOrder
) { deals, configs, keywords, keywordOrder ->
// 사용자가 설정한 키워드 순서에 따라 정렬
val domainKeywords = keywords.map { it.toDomain() }
val sortedKeywords = if (keywordOrder.isEmpty()) {
domainKeywords
} else {
val orderMap = keywordOrder.withIndex().associate { it.value to it.index }
domainKeywords.sortedBy { orderMap[it.id] ?: Int.MAX_VALUE }
}
MainUiState.Success(
deals = deals.map { it.toDomain() },
siteConfigs = configs.map { it.toDomain() },
keywords = keywords.map { it.toDomain() }
keywords = sortedKeywords
)
}.catch { e ->
_uiState.value = MainUiState.Error(e.message ?: "Unknown error")
@@ -92,21 +108,39 @@ class MainViewModel @Inject constructor(
}
}
fun reorderSites(fromIndex: Int, toIndex: Int) {
viewModelScope.launch {
val current = siteOrder.value.toMutableList()
if (fromIndex in current.indices && toIndex in current.indices) {
val item = current.removeAt(fromIndex)
current.add(toIndex, item)
appSettings.setSiteOrder(current)
}
}
}
fun addKeyword(keyword: String) {
if (keyword.isBlank()) return
viewModelScope.launch {
keywordDao.insertKeyword(
com.hotdeal.alarm.data.local.db.entity.KeywordEntity(
val newId = keywordDao.insertKeyword(
KeywordEntity(
keyword = keyword.trim(),
isEnabled = true
)
)
// 키워드 순서 맨 앞에 추가
val currentOrder = appSettings.keywordOrder.first().toMutableList()
currentOrder.add(0, newId)
appSettings.setKeywordOrder(currentOrder)
}
}
fun deleteKeyword(id: Long) {
viewModelScope.launch {
keywordDao.deleteKeywordById(id)
val currentOrder = appSettings.keywordOrder.first().toMutableList()
currentOrder.remove(id)
appSettings.setKeywordOrder(currentOrder)
}
}
@@ -116,18 +150,24 @@ class MainViewModel @Inject constructor(
}
}
/**
* 즐겨찾기 토글
*/
fun reorderKeywords(fromIndex: Int, toIndex: Int) {
val state = uiState.value as? MainUiState.Success ?: return
viewModelScope.launch {
val current = state.keywords.toMutableList()
if (fromIndex in current.indices && toIndex in current.indices) {
val item = current.removeAt(fromIndex)
current.add(toIndex, item)
appSettings.setKeywordOrder(current.map { it.id })
}
}
}
fun toggleFavorite(dealId: String) {
viewModelScope.launch {
hotDealDao.toggleFavorite(dealId)
}
}
/**
* 즐겨찾기 설정
*/
fun setFavorite(dealId: String, isFavorite: Boolean) {
viewModelScope.launch {
hotDealDao.setFavorite(dealId, isFavorite)
@@ -138,9 +178,6 @@ class MainViewModel @Inject constructor(
workerScheduler.executeOnce()
}
/**
* 시작 (주기 저장)
*/
fun startPolling(intervalMinutes: Long = WorkerScheduler.DEFAULT_INTERVAL_MINUTES) {
viewModelScope.launch {
appSettings.setPollingInterval(intervalMinutes.toInt())
@@ -152,20 +189,20 @@ class MainViewModel @Inject constructor(
workerScheduler.cancelPolling()
}
// 데이터 파싱 핫딜 데이터 전체 삭제 및 사용자 피드백 트리거
private val _toastEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
val toastEvent = _toastEvent.asSharedFlow()
fun deleteAllParsedData() {
viewModelScope.launch {
try {
hotDealDao.deleteAllDeals()
_toastEvent.emit("파싱 데이터가 삭제되었습니다")
_toastEvent.emit("모든 수집 데이터가 삭제되었습니다")
} catch (e: Exception) {
_toastEvent.emit("데이터 삭제 중 오류가 발생했습니다: ${e.message}")
}
}
}
}
sealed class MainUiState {
data object Loading : MainUiState()
object Loading : MainUiState()
data class Success(
val deals: List<com.hotdeal.alarm.domain.model.HotDeal>,
val siteConfigs: List<com.hotdeal.alarm.domain.model.SiteConfig>,
@@ -14,11 +14,10 @@ import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
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.*
@@ -29,7 +28,6 @@ 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.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
@@ -42,20 +40,19 @@ 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.*
import com.hotdeal.alarm.util.ApkDownloadManager
import com.hotdeal.alarm.util.PermissionHelper
import com.hotdeal.alarm.util.VersionManager
import kotlinx.coroutines.launch
/**
* One UI 9 & Material 3 Expressive Bento Settings Screen
* 인체공학적 세그먼트 Pill 모듈형 Bento 그리드 구조 프리미엄 설정 화면
* 순서 변경(Reordering) Bento 그리드 구조 지원하는 프리미엄 설정 화면
*/
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsState()
val scope = rememberCoroutineScope()
val currentPollingInterval by viewModel.pollingInterval.collectAsState()
@@ -79,7 +76,6 @@ fun SettingsScreen(viewModel: MainViewModel) {
}
val permissionStatus = PermissionHelper.checkAllPermissions(context)
val hasEnabledSites = (uiState as? MainUiState.Success)?.siteConfigs?.any { it.isEnabled } ?: false
Column(
modifier = Modifier
@@ -92,7 +88,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
.padding(horizontal = 14.dp, vertical = 6.dp),
shape = CornerRadius.shapePill,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
border = androidx.compose.foundation.BorderStroke(
@@ -103,8 +99,8 @@ fun SettingsScreen(viewModel: MainViewModel) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp)
.padding(3.dp),
horizontalArrangement = Arrangement.spacedBy(3.dp)
) {
tabTitles.forEachIndexed { index, title ->
val isSelected = pagerState.currentPage == index
@@ -114,7 +110,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
Box(
modifier = Modifier
.weight(1f)
.height(38.dp)
.height(36.dp)
.clip(CornerRadius.shapePill)
.background(tabBgColor)
.elasticPressClickable {
@@ -124,8 +120,9 @@ fun SettingsScreen(viewModel: MainViewModel) {
) {
Text(
text = title,
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
fontSize = 12.sp
),
color = tabTextColor
)
@@ -146,7 +143,6 @@ fun SettingsScreen(viewModel: MainViewModel) {
0 -> NotificationTab(
viewModel = viewModel,
permissionStatus = permissionStatus,
hasEnabledSites = hasEnabledSites,
currentPollingInterval = currentPollingInterval,
onRequestPermission = {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
@@ -177,12 +173,11 @@ fun SettingsScreen(viewModel: MainViewModel) {
private fun NotificationTab(
viewModel: MainViewModel,
permissionStatus: PermissionHelper.PermissionStatus,
hasEnabledSites: Boolean,
currentPollingInterval: Int,
onRequestPermission: () -> Unit
) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsState()
var keywordInput by remember { mutableStateOf("") }
val activeKeywords = (uiState as? MainUiState.Success)?.keywords ?: emptyList()
@@ -191,17 +186,17 @@ private fun NotificationTab(
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = 32.dp)
.padding(horizontal = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp)
) {
// One UI 9 Now Status Bento 2x2 카드
// Now Status Bento 2x2 카드
item {
SquircleCard(
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -210,7 +205,7 @@ private fun NotificationTab(
imageVector = Icons.Outlined.Dashboard,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(17.dp)
)
Text(
text = "실시간 모니터링 요약",
@@ -219,11 +214,11 @@ private fun NotificationTab(
)
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(10.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
BentoMiniTile(
title = "알림 상태",
@@ -239,11 +234,11 @@ private fun NotificationTab(
)
}
Spacer(modifier = Modifier.height(10.dp))
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
BentoMiniTile(
title = "활성 사이트",
@@ -266,7 +261,7 @@ private fun NotificationTab(
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -275,7 +270,7 @@ private fun NotificationTab(
imageVector = Icons.Outlined.Timer,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(17.dp)
)
Text(
text = "수집 주기 설정",
@@ -284,20 +279,19 @@ private fun NotificationTab(
)
}
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "백그라운드에서 새로운 핫딜을 확인하는 주기입니다.",
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.5.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(10.dp))
// 1분 / 2분 / 5분 / 10분 / 30분 Pill 선택기
val intervals = listOf(1, 2, 5, 10, 30)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp)
horizontalArrangement = Arrangement.spacedBy(5.dp)
) {
intervals.forEach { minutes ->
val isSelected = currentPollingInterval == minutes
@@ -307,7 +301,7 @@ private fun NotificationTab(
Box(
modifier = Modifier
.weight(1f)
.height(38.dp)
.height(34.dp)
.clip(CornerRadius.shapeSmall)
.background(bgColor)
.elasticPressClickable {
@@ -319,7 +313,7 @@ private fun NotificationTab(
) {
Text(
text = "${minutes}",
style = MaterialTheme.typography.labelMedium.copy(
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
),
color = textColor
@@ -331,13 +325,13 @@ private fun NotificationTab(
}
}
// 키워드 알림 관리 섹션
// 키워드 등록 입력창
item {
SquircleCard(
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -346,7 +340,7 @@ private fun NotificationTab(
imageVector = Icons.Outlined.NotificationsActive,
contentDescription = null,
tint = KeywordGold,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(17.dp)
)
Text(
text = "키워드 등록 & 관리",
@@ -355,16 +349,15 @@ private fun NotificationTab(
)
}
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "등록한 키워드가 포함된 핫딜이 발견되면 즉시 알림을 발송합니다.",
style = MaterialTheme.typography.bodySmall,
text = "키워드가 포함된 핫딜 수집 시 알림을 발송합니다.",
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.5.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(14.dp))
Spacer(modifier = Modifier.height(10.dp))
// 키워드 입력 필드 + 추가 버튼 일체형
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
@@ -375,7 +368,7 @@ private fun NotificationTab(
onValueChange = { keywordInput = it },
placeholder = {
Text(
text = "키워드 입력 (예: 모니터, 칫솔, 그래픽카드)",
text = "키워드 입력 (예: 모니터, 그래픽카드)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
)
@@ -399,36 +392,56 @@ private fun NotificationTab(
}
},
shape = CornerRadius.shapeNormal,
modifier = Modifier.height(52.dp),
modifier = Modifier.height(50.dp),
enabled = keywordInput.isNotBlank()
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "추가",
modifier = Modifier.size(18.dp)
modifier = Modifier.size(17.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text(text = "추가", fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.width(3.dp))
Text(text = "추가", fontWeight = FontWeight.Bold, fontSize = 13.sp)
}
}
}
}
}
// 등록된 키워드 목록
// 등록된 키워드 목록 (순서 조절 가능)
if (activeKeywords.isNotEmpty()) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "등록된 키워드 (${activeKeywords.size}개)",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp)
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.weight(1f))
Text(
text = "▲▼ 버튼으로 순서 조절",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
items(activeKeywords, key = { it.id }) { keyword ->
items(
count = activeKeywords.size,
key = { index -> activeKeywords[index].id }
) { index ->
val keyword = activeKeywords[index]
OneUIKeywordTile(
keyword = keyword,
isFirst = index == 0,
isLast = index == activeKeywords.lastIndex,
onMoveUp = { viewModel.reorderKeywords(index, index - 1) },
onMoveDown = { viewModel.reorderKeywords(index, index + 1) },
onToggle = { viewModel.toggleKeyword(keyword.id, !keyword.isEnabled) },
onDelete = { viewModel.deleteKeyword(keyword.id) }
)
@@ -438,62 +451,68 @@ private fun NotificationTab(
}
// ============================================
// 탭 2: 사이트 관리 (SitesTab)
// 탭 2: 사이트 관리 (SitesTab - 순서 조절 지원)
// ============================================
@Composable
private fun SitesTab(viewModel: MainViewModel) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val uiState by viewModel.uiState.collectAsState()
val siteOrder by viewModel.siteOrder.collectAsState()
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = 32.dp)
.padding(horizontal = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp)
) {
item {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
.padding(horizontal = 4.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Language,
imageVector = Icons.Outlined.SwapVert,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Column {
Spacer(modifier = Modifier.width(6.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "수집 대상 커뮤니티",
text = "수집 대상 커뮤니티 및 우선순위",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
Text(
text = "원하는 사이트 및 세부 게시판을 켜고 끌 수 있습니다.",
style = MaterialTheme.typography.bodySmall,
text = "▲▼ 버튼으로 순서를 변경하면 메인 필터 칩에 즉시 반영됩니다.",
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
when (val state = uiState) {
is MainUiState.Success -> {
SiteType.entries.forEach { site ->
val configs = state.siteConfigs.filter { it.siteName == site.name }
item(key = site.name) {
val successState = uiState as? MainUiState.Success
if (successState != null) {
items(
count = siteOrder.size,
key = { index -> siteOrder[index].name }
) { index ->
val site = siteOrder[index]
val configs = successState.siteConfigs.filter { it.siteName == site.name }
OneUISiteBentoCard(
siteType = site,
configs = configs,
isFirst = index == 0,
isLast = index == siteOrder.lastIndex,
onMoveUp = { viewModel.reorderSites(index, index - 1) },
onMoveDown = { viewModel.reorderSites(index, index + 1) },
onToggle = { key, enabled -> viewModel.toggleSiteConfig(key, enabled) }
)
}
}
}
else -> {
} else {
item {
Box(
modifier = Modifier
@@ -506,7 +525,6 @@ private fun SitesTab(viewModel: MainViewModel) {
}
}
}
}
}
// ============================================
@@ -518,7 +536,6 @@ private fun MoreTab(viewModel: MainViewModel) {
val scope = rememberCoroutineScope()
var showDeleteDialog by remember { mutableStateOf(false) }
var isCheckingUpdate by remember { mutableStateOf(false) }
var isDownloading by remember { mutableStateOf(false) }
val toastEvent by viewModel.toastEvent.collectAsStateWithLifecycle(initialValue = null)
@@ -531,11 +548,11 @@ private fun MoreTab(viewModel: MainViewModel) {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = 32.dp)
.padding(horizontal = 14.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp)
) {
// One UI 9 App Hero Info Card
// App Hero Info Card
item {
SquircleCard(
containerColor = MaterialTheme.colorScheme.surface,
@@ -544,12 +561,12 @@ private fun MoreTab(viewModel: MainViewModel) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(20.dp),
.padding(18.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(64.dp)
.size(56.dp)
.clip(CornerRadius.shapeSquircle)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
@@ -558,27 +575,27 @@ private fun MoreTab(viewModel: MainViewModel) {
imageVector = Icons.Filled.LocalFireDepartment,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(36.dp)
modifier = Modifier.size(32.dp)
)
}
Spacer(modifier = Modifier.height(12.dp))
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "핫딜 알람 (HotDeal Alarm)",
style = MaterialTheme.typography.titleLarge,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "v${VersionManager.getCurrentVersion(context)} • One UI 9 Edition",
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(16.dp))
Spacer(modifier = Modifier.height(14.dp))
Button(
onClick = {
@@ -596,18 +613,19 @@ private fun MoreTab(viewModel: MainViewModel) {
}
},
shape = CornerRadius.shapeNormal,
modifier = Modifier.height(44.dp),
enabled = !isCheckingUpdate && !isDownloading
modifier = Modifier.height(42.dp),
enabled = !isCheckingUpdate
) {
Icon(
imageVector = Icons.Outlined.SystemUpdate,
contentDescription = null,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(6.dp))
Text(
text = if (isCheckingUpdate) "확인 중..." else "업데이트 확인",
fontWeight = FontWeight.SemiBold
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
)
}
}
@@ -620,7 +638,7 @@ private fun MoreTab(viewModel: MainViewModel) {
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Column(modifier = Modifier.padding(14.dp)) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
@@ -629,7 +647,7 @@ private fun MoreTab(viewModel: MainViewModel) {
imageVector = Icons.Outlined.Storage,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(17.dp)
)
Text(
text = "데이터 및 캐시 관리",
@@ -638,14 +656,14 @@ private fun MoreTab(viewModel: MainViewModel) {
)
}
Spacer(modifier = Modifier.height(6.dp))
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "수집되어 로컬 DB에 캐싱된 모든 핫딜 데이터를 일괄 삭제합니다.",
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.5.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(14.dp))
Spacer(modifier = Modifier.height(12.dp))
FilledTonalButton(
onClick = { showDeleteDialog = true },
@@ -654,15 +672,15 @@ private fun MoreTab(viewModel: MainViewModel) {
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
),
modifier = Modifier.fillMaxWidth().height(48.dp)
modifier = Modifier.fillMaxWidth().height(44.dp)
) {
Icon(
imageVector = Icons.Outlined.DeleteSweep,
contentDescription = null,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "수집 데이터 전체 비우기", fontWeight = FontWeight.Bold)
Spacer(modifier = Modifier.width(6.dp))
Text(text = "수집 데이터 전체 비우기", fontWeight = FontWeight.Bold, fontSize = 13.sp)
}
}
}
@@ -695,7 +713,7 @@ private fun MoreTab(viewModel: MainViewModel) {
}
// ============================================
// 서브 컴포넌트들 (One UI 9 UI Tiles)
// 서브 컴포넌트들 (순서 조절 버튼 포함)
// ============================================
@Composable
@@ -723,18 +741,18 @@ private fun BentoMiniTile(
.clip(CornerRadius.shapeNormal)
.background(bgColor)
.then(if (onClick != null) Modifier.elasticPressClickable(onClick = onClick) else Modifier)
.padding(horizontal = 12.dp, vertical = 10.dp)
.padding(horizontal = 10.dp, vertical = 8.dp)
) {
Column {
Text(
text = title,
style = MaterialTheme.typography.labelSmall,
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.5.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = value,
style = MaterialTheme.typography.labelLarge.copy(fontWeight = FontWeight.Bold),
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold, fontSize = 12.5.sp),
color = textColor
)
}
@@ -744,51 +762,88 @@ private fun BentoMiniTile(
@Composable
private fun OneUIKeywordTile(
keyword: Keyword,
isFirst: Boolean,
isLast: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
SquircleCard(
shape = CornerRadius.shapeNormal,
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 순서 조절 상/하 버튼
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
IconButton(
onClick = onMoveUp,
enabled = !isFirst,
modifier = Modifier.size(22.dp)
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = "위로 이동",
tint = if (!isFirst) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.size(16.dp)
)
}
IconButton(
onClick = onMoveDown,
enabled = !isLast,
modifier = Modifier.size(22.dp)
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "아래로 이동",
tint = if (!isLast) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.size(16.dp)
)
}
}
Spacer(modifier = Modifier.width(6.dp))
Box(
modifier = Modifier
.size(8.dp)
.size(7.dp)
.background(KeywordGold, CircleShape)
)
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = keyword.keyword,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold),
modifier = Modifier.weight(1f)
)
Switch(
checked = keyword.isEnabled,
onCheckedChange = { onToggle() },
modifier = Modifier.scale(0.85f)
modifier = Modifier.scale(0.8f)
)
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(2.dp))
IconButton(
onClick = onDelete,
modifier = Modifier.size(32.dp)
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = Icons.Outlined.Delete,
contentDescription = "삭제",
tint = MaterialTheme.colorScheme.outline,
modifier = Modifier.size(18.dp)
modifier = Modifier.size(16.dp)
)
}
}
@@ -799,6 +854,10 @@ private fun OneUIKeywordTile(
private fun OneUISiteBentoCard(
siteType: SiteType,
configs: List<SiteConfig>,
isFirst: Boolean,
isLast: Boolean,
onMoveUp: () -> Unit,
onMoveDown: () -> Unit,
onToggle: (String, Boolean) -> Unit
) {
val siteColor = getSiteColor(siteType)
@@ -806,43 +865,77 @@ private fun OneUISiteBentoCard(
var isExpanded by remember { mutableStateOf(false) }
SquircleCard(
shape = CornerRadius.shapeNormal,
containerColor = MaterialTheme.colorScheme.surface,
borderColor = if (isAnyEnabled) siteColor.copy(alpha = 0.4f) else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f),
borderWidth = if (isAnyEnabled) 1.2.dp else 0.8.dp,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
// 상단 마스터 행
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 순서 조절 버튼
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
IconButton(
onClick = onMoveUp,
enabled = !isFirst,
modifier = Modifier.size(22.dp)
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = "위로 이동",
tint = if (!isFirst) siteColor else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.size(16.dp)
)
}
IconButton(
onClick = onMoveDown,
enabled = !isLast,
modifier = Modifier.size(22.dp)
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "아래로 이동",
tint = if (!isLast) siteColor else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f),
modifier = Modifier.size(16.dp)
)
}
}
Spacer(modifier = Modifier.width(6.dp))
// 브랜드 뱃지
Surface(
shape = CornerRadius.shapeSmall,
color = siteColor.copy(alpha = 0.14f),
modifier = Modifier.size(36.dp)
modifier = Modifier.size(32.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = siteType.displayName.take(1),
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.ExtraBold),
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.ExtraBold),
color = siteColor
)
}
}
Spacer(modifier = Modifier.width(12.dp))
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = siteType.displayName,
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Bold),
style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold),
color = MaterialTheme.colorScheme.onSurface
)
Text(
text = "${configs.count { it.isEnabled }}/${configs.size}개 게시판 활성화",
style = MaterialTheme.typography.bodySmall,
style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
@@ -855,12 +948,12 @@ private fun OneUISiteBentoCard(
onToggle(config.siteBoardKey, enableAll)
}
},
modifier = Modifier.scale(0.9f)
modifier = Modifier.scale(0.85f)
)
IconButton(
onClick = { isExpanded = !isExpanded },
modifier = Modifier.size(32.dp)
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown,
@@ -879,13 +972,13 @@ private fun OneUISiteBentoCard(
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp)
.padding(top = 8.dp)
) {
Divider(
HorizontalDivider(
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f),
thickness = 0.8.dp
)
Spacer(modifier = Modifier.height(8.dp))
Spacer(modifier = Modifier.height(6.dp))
siteType.boards.forEach { board ->
val config = configs.find { it.boardName == board.id }
@@ -895,19 +988,19 @@ private fun OneUISiteBentoCard(
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 4.dp),
.padding(vertical = 3.dp, horizontal = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = board.displayName,
style = MaterialTheme.typography.bodyMedium,
style = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.sp),
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f)
)
Switch(
checked = isEnabled,
onCheckedChange = { onToggle(key, it) },
modifier = Modifier.scale(0.8f)
modifier = Modifier.scale(0.75f)
)
}
}
+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.8",
"versionCode": 28,
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases/download/v0.2.8/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) 핫딜 채널 추가"
"설정 > 사이트 관리에서 사이트 순서 조절 기능 추가 (메인 필터 칩 순서 실시간 연동)",
"설정 > 등록된 키워드 목록에서 키워드 순서 조절 기능 추가",
"하단 고정 메뉴 높이 및 마진 슬림화로 메인 화면 영역 추가 확보"
]
}