19 Commits
Author SHA1 Message Date
sanjeok77 e06e5b744a v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-20 23:59:48 +00:00
sanjeok77 17efbe80db v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-20 23:59:46 +00:00
sanjeok77 6c55b9eeec v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-20 23:59:44 +00:00
sanjeok77 5d610eab02 v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-20 23:59:43 +00:00
sanjeok77 9fd97ae337 v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-20 23:59:41 +00:00
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
10 changed files with 985 additions and 543 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 = 29
versionName = "0.2.9"
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)
)
}
}
@@ -0,0 +1,138 @@
package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.lazy.LazyListItemInfo
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
/**
* Compose LazyColumn Drag & Drop Reordering State
*/
@Composable
fun rememberDragDropListState(
lazyListState: LazyListState,
onMove: (Int, Int) -> Unit,
onDragEnd: () -> Unit = {}
): DragDropListState {
val scope = rememberCoroutineScope()
val state = remember(lazyListState) {
DragDropListState(
lazyListState = lazyListState,
onMove = onMove,
onDragEnd = onDragEnd,
scope = scope
)
}
return state
}
class DragDropListState(
val lazyListState: LazyListState,
private val onMove: (Int, Int) -> Unit,
private val onDragEnd: () -> Unit,
private val scope: CoroutineScope
) {
var initiallyDraggedElement by mutableStateOf<LazyListItemInfo?>(null)
private set
var currentIndexOfDraggedItem by mutableStateOf<Int?>(null)
private set
private val dragOffset = Animatable(0f)
val elementOffset: Float
get() = dragOffset.value
fun onDragStart(offset: Offset) {
lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull { item -> offset.y.toInt() in item.offset..(item.offset + item.size) }
?.also { item ->
currentIndexOfDraggedItem = item.index
initiallyDraggedElement = item
}
}
fun onDragInterrupted() {
initiallyDraggedElement = null
currentIndexOfDraggedItem = null
scope.launch {
dragOffset.snapTo(0f)
}
}
fun onDrag(change: Offset) {
scope.launch {
dragOffset.snapTo(dragOffset.value + change.y)
val currentElement = initiallyDraggedElement ?: return@launch
val startOffset = currentElement.offset + dragOffset.value
val endOffset = startOffset + currentElement.size
val hoveredItem = lazyListState.layoutInfo.visibleItemsInfo
.firstOrNull { item ->
val itemMid = item.offset + item.size / 2
val isMidInRange = itemMid in startOffset.toInt()..endOffset.toInt()
isMidInRange && item.index != currentIndexOfDraggedItem
}
if (hoveredItem != null) {
val currentIndex = currentIndexOfDraggedItem ?: return@launch
val targetIndex = hoveredItem.index
onMove(currentIndex, targetIndex)
currentIndexOfDraggedItem = targetIndex
}
}
}
fun onDragStop() {
onDragEnd()
scope.launch {
dragOffset.animateTo(0f, spring(stiffness = Spring.StiffnessMediumLow))
initiallyDraggedElement = null
currentIndexOfDraggedItem = null
}
}
}
fun Modifier.dragDropGesture(
dragDropState: DragDropListState
): Modifier = this.pointerInput(dragDropState) {
detectDragGesturesAfterLongPress(
onDragStart = { offset -> dragDropState.onDragStart(offset) },
onDragEnd = { dragDropState.onDragStop() },
onDragCancel = { dragDropState.onDragInterrupted() },
onDrag = { change, dragAmount ->
change.consume()
dragDropState.onDrag(dragAmount)
}
)
}
fun Modifier.dragDropItem(
index: Int,
dragDropState: DragDropListState
): Modifier = this.then(
if (index == dragDropState.currentIndexOfDraggedItem) {
Modifier
.zIndex(10f)
.graphicsLayer {
translationY = dragDropState.elementOffset
scaleX = 1.03f
scaleY = 1.03f
shadowElevation = 8f
}
} else {
Modifier
}
)
@@ -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.rememberLazyListState
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
@@ -44,18 +42,19 @@ 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.UpdateInfo
import com.hotdeal.alarm.util.VersionManager
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* One UI 9 & Material 3 Expressive Bento Settings Screen
* 인체공학적 세그먼트 Pill 탭 및 모듈형 Bento 그리드 구조의 프리미엄 설정 화면
* 완벽한 터치 드래그 앤 드롭(Drag & Drop) 및 OTA 자동 설치를 지원하는 설정 화면
*/
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val currentPollingInterval by viewModel.pollingInterval.collectAsState()
@@ -79,7 +78,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 +90,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 +101,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 +112,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
Box(
modifier = Modifier
.weight(1f)
.height(38.dp)
.height(36.dp)
.clip(CornerRadius.shapePill)
.background(tabBgColor)
.elasticPressClickable {
@@ -124,8 +122,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 +145,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) {
@@ -171,37 +169,52 @@ fun SettingsScreen(viewModel: MainViewModel) {
}
// ============================================
// 탭 1: 알림 & 키워드 (NotificationTab)
// 탭 1: 알림 & 키워드 (NotificationTab - 드래그앤드롭 Reordering)
// ============================================
@Composable
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()
val activeSitesCount = (uiState as? MainUiState.Success)?.siteConfigs?.count { it.isEnabled } ?: 0
val listState = rememberLazyListState()
val dragDropState = rememberDragDropListState(
lazyListState = listState,
onMove = { from, to ->
// 실시간 리스트 재배치
val itemOffset = 3 // 상단 Bento, 주기설정, 키워드인풋 3개 아이템 제외
val keywordFrom = from - itemOffset
val keywordTo = to - itemOffset
if (keywordFrom in activeKeywords.indices && keywordTo in activeKeywords.indices) {
viewModel.reorderKeywords(keywordFrom, keywordTo)
}
}
)
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = 32.dp)
.padding(horizontal = 14.dp)
.dragDropGesture(dragDropState),
verticalArrangement = Arrangement.spacedBy(10.dp),
contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp)
) {
// One UI 9 Now Status Bento 2x2 카드
// 0: 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 +223,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 +232,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 +252,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 = "활성 사이트",
@@ -260,13 +273,13 @@ private fun NotificationTab(
}
}
// 모듈형 수집 주기 선택 블록
// 1: 모듈형 수집 주기 선택 블록
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)
@@ -275,7 +288,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 +297,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 +319,7 @@ private fun NotificationTab(
Box(
modifier = Modifier
.weight(1f)
.height(38.dp)
.height(34.dp)
.clip(CornerRadius.shapeSmall)
.background(bgColor)
.elasticPressClickable {
@@ -319,7 +331,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 +343,13 @@ private fun NotificationTab(
}
}
// 키워드 알림 관리 섹션
// 2: 키워드 등록 입력창
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 +358,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 +367,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 +386,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 +410,66 @@ 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)
}
}
}
}
}
// 등록된 키워드 목록
// 등록된 키워드 목록 (드래그 앤 드롭 Reordering)
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))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.size(14.dp)
)
Text(
text = "길게 눌러 드래그앤드롭",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
items(
count = activeKeywords.size,
key = { index -> activeKeywords[index].id }
) { index ->
val keyword = activeKeywords[index]
val itemIndex = index + 4 // 헤더 3개 + 서브헤더 1개
items(activeKeywords, key = { it.id }) { keyword ->
OneUIKeywordTile(
keyword = keyword,
modifier = Modifier.dragDropItem(itemIndex, dragDropState),
onToggle = { viewModel.toggleKeyword(keyword.id, !keyword.isEnabled) },
onDelete = { viewModel.deleteKeyword(keyword.id) }
)
@@ -438,62 +479,81 @@ 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()
val listState = rememberLazyListState()
val dragDropState = rememberDragDropListState(
lazyListState = listState,
onMove = { from, to ->
val fromSiteIndex = from - 1 // 상단 헤더 1개 제외
val toSiteIndex = to - 1
if (fromSiteIndex in siteOrder.indices && toSiteIndex in siteOrder.indices) {
viewModel.reorderSites(fromSiteIndex, toSiteIndex)
}
}
)
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp),
contentPadding = PaddingValues(top = 8.dp, bottom = 32.dp)
.padding(horizontal = 14.dp)
.dragDropGesture(dragDropState),
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.DragIndicator,
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 }
val itemIndex = index + 1
OneUISiteBentoCard(
siteType = site,
configs = configs,
modifier = Modifier.dragDropItem(itemIndex, dragDropState),
onToggle = { key, enabled -> viewModel.toggleSiteConfig(key, enabled) }
)
}
}
}
else -> {
} else {
item {
Box(
modifier = Modifier
@@ -507,20 +567,24 @@ private fun SitesTab(viewModel: MainViewModel) {
}
}
}
}
// ============================================
// 탭 3: 데이터 & 정보 (MoreTab)
// 탭 3: 데이터 & 정보 (MoreTab - 자동 다운로드 & 설치 화면 호출)
// ============================================
@Composable
private fun MoreTab(viewModel: MainViewModel) {
val context = LocalContext.current
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)
var isCheckingUpdate by remember { mutableStateOf(false) }
var availableUpdateInfo by remember { mutableStateOf<UpdateInfo?>(null) }
var showUpdateDialog by remember { mutableStateOf(false) }
var isDownloading by remember { mutableStateOf(false) }
var downloadProgress by remember { mutableStateOf(0) }
val toastEvent by viewModel.toastEvent.collectAsState(initial = null)
LaunchedEffect(toastEvent) {
toastEvent?.let { msg ->
@@ -531,11 +595,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 +608,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,28 +622,45 @@ 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))
if (isDownloading) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp)
) {
LinearProgressIndicator(
progress = { downloadProgress / 100f },
modifier = Modifier.fillMaxWidth().height(6.dp).clip(CornerRadius.shapePill)
)
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "업데이트 다운로드 중... ($downloadProgress%)",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary
)
}
} else {
Button(
onClick = {
scope.launch {
@@ -589,30 +670,33 @@ private fun MoreTab(viewModel: MainViewModel) {
isCheckingUpdate = false
if (remoteInfo != null && VersionManager.isUpdateAvailable(currentCode, remoteInfo.versionCode)) {
Toast.makeText(context, "새 버전(${remoteInfo.version})이 있습니다!", Toast.LENGTH_LONG).show()
availableUpdateInfo = remoteInfo
showUpdateDialog = true
} else {
Toast.makeText(context, "최신 버전을 사용 중입니다", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "최신 버전을 사용 중입니다 (v${VersionManager.getCurrentVersion(context)})", Toast.LENGTH_SHORT).show()
}
}
},
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
)
}
}
}
}
}
// 데이터 관리 Bento 카드
item {
@@ -620,7 +704,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 +713,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 +722,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,19 +738,115 @@ 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(6.dp))
Text(text = "수집 데이터 전체 비우기", fontWeight = FontWeight.Bold, fontSize = 13.sp)
}
}
}
}
}
// 새 버전 업데이트 발견 다이얼로그 (Changelog 및 즉시 다운로드/설치)
if (showUpdateDialog && availableUpdateInfo != null) {
val info = availableUpdateInfo!!
AlertDialog(
onDismissRequest = { if (!isDownloading) showUpdateDialog = false },
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.NewReleases,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = "수집 데이터 전체 비우기", fontWeight = FontWeight.Bold)
Text("새 버전 업데이트 (v${info.version})")
}
},
text = {
Column {
Text(
text = "최신 버전으로 업데이트할 수 있습니다.",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
if (info.changelog.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "업데이트 내용:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(4.dp))
info.changelog.forEach { log ->
Text(
text = "$log",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
confirmButton = {
Button(
onClick = {
showUpdateDialog = false
isDownloading = true
downloadProgress = 0
val downloadId = ApkDownloadManager.downloadApk(context, info)
// 1. 브로드캐스트 리시버로 다운로드 완료 감지
ApkDownloadManager.registerDownloadCompleteReceiver(
context = context,
downloadId = downloadId,
onComplete = {
isDownloading = false
downloadProgress = 100
// 다운로드 완료 즉시 안드로이드 시스템 설치 화면 실행!
ApkDownloadManager.installApk(context)
},
onFailed = {
isDownloading = false
Toast.makeText(context, "다운로드에 실패했습니다.", Toast.LENGTH_SHORT).show()
}
)
// 2. 프로그레스 폴링 코루틴
scope.launch {
while (isDownloading) {
delay(500)
val status = ApkDownloadManager.getDownloadStatus(context, downloadId)
downloadProgress = status.progress
if (status.isComplete) {
isDownloading = false
ApkDownloadManager.installApk(context)
break
}
if (status.isFailed) {
isDownloading = false
break
}
}
}
}
) {
Text("지금 업데이트 & 설치")
}
},
dismissButton = {
TextButton(onClick = { showUpdateDialog = false }) {
Text("나중에")
}
}
)
}
if (showDeleteDialog) {
@@ -695,7 +875,7 @@ private fun MoreTab(viewModel: MainViewModel) {
}
// ============================================
// 서브 컴포넌트들 (One UI 9 UI Tiles)
// 서브 컴포넌트들 (드래그 핸들러 포함)
// ============================================
@Composable
@@ -723,18 +903,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 +924,61 @@ private fun BentoMiniTile(
@Composable
private fun OneUIKeywordTile(
keyword: Keyword,
modifier: Modifier = Modifier,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
SquircleCard(
shape = CornerRadius.shapeNormal,
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth()
modifier = modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "드래그하여 순서 변경",
tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.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 +989,7 @@ private fun OneUIKeywordTile(
private fun OneUISiteBentoCard(
siteType: SiteType,
configs: List<SiteConfig>,
modifier: Modifier = Modifier,
onToggle: (String, Boolean) -> Unit
) {
val siteColor = getSiteColor(siteType)
@@ -806,43 +997,53 @@ 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()
modifier = modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Column(modifier = Modifier.padding(12.dp)) {
// 상단 마스터 행
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.DragHandle,
contentDescription = "드래그하여 순서 변경",
tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.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 +1056,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 +1080,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 +1096,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)
)
}
}
@@ -25,7 +25,6 @@ object ApkDownloadManager {
private const val TAG = "ApkDownloadManager"
private const val APK_FILE_NAME = "hotdeal-alarm-update.apk"
// 등록된 리시버 추적 (메모리 누수 방지)
private var registeredReceiver: BroadcastReceiver? = null
/**
@@ -34,31 +33,29 @@ object ApkDownloadManager {
fun downloadApk(context: Context, updateInfo: UpdateInfo): Long {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// 기존 파일 삭제
val outputFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), APK_FILE_NAME)
val outputDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
if (outputDir != null && !outputDir.exists()) {
outputDir.mkdirs()
}
val outputFile = File(outputDir, APK_FILE_NAME)
if (outputFile.exists()) {
outputFile.delete()
}
val request = DownloadManager.Request(Uri.parse(updateInfo.updateUrl)).apply {
setTitle("핫딜 알람 업데이트")
setDescription("버전 ${updateInfo.version} 다운로드 중...")
setDescription("v${updateInfo.version} 다운로드 중...")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, APK_FILE_NAME)
setAllowedOverMetered(true)
setAllowedOverRoaming(true)
setMimeType("application/vnd.android.package-archive")
// Wi-Fi 환경에서 다운로드 우선
setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
}
val downloadId = downloadManager.enqueue(request)
Toast.makeText(
context,
"업데이트 다운로드 시작...",
Toast.LENGTH_SHORT
).show()
Log.d(TAG, "다운로드 큐에 추가됨: downloadId=$downloadId, url=${updateInfo.updateUrl}")
return downloadId
}
@@ -72,14 +69,13 @@ object ApkDownloadManager {
onComplete: () -> Unit,
onFailed: () -> Unit
): BroadcastReceiver {
// 기존 리시버가 있으면 먼저 해제
unregisterDownloadCompleteReceiver(context)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
override fun onReceive(receivedContext: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1
if (id == downloadId) {
val downloadManager = context?.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
val downloadManager = receivedContext?.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager?.query(query)
@@ -90,16 +86,14 @@ object ApkDownloadManager {
when (status) {
DownloadManager.STATUS_SUCCESSFUL -> {
Log.d(TAG, "다운로드 완료, 설치 시작")
Log.d(TAG, "다운로드 완료 감지, onComplete 호출")
onComplete()
// 설치 후 리시버 해제
unregisterDownloadCompleteReceiver(context)
unregisterDownloadCompleteReceiver(receivedContext ?: context)
}
DownloadManager.STATUS_FAILED -> {
Log.e(TAG, "다운로드 실패")
Log.e(TAG, "다운로드 실패 감지")
onFailed()
// 실패 시 리시버 해제
unregisterDownloadCompleteReceiver(context)
unregisterDownloadCompleteReceiver(receivedContext ?: context)
}
}
}
@@ -108,12 +102,11 @@ object ApkDownloadManager {
}
}
// Android 12+ 에서는 RECEIVER_NOT_EXPORTED 플래그 필요
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
receiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
Context.RECEIVER_NOT_EXPORTED
Context.RECEIVER_EXPORTED
)
} else {
context.registerReceiver(
@@ -123,29 +116,20 @@ object ApkDownloadManager {
}
registeredReceiver = receiver
Log.d(TAG, "다운로드 리시버 등록됨, downloadId=$downloadId")
return receiver
}
/**
* 다운로드 완료 리시버 해제
*/
fun unregisterDownloadCompleteReceiver(context: Context) {
registeredReceiver?.let { receiver ->
try {
context.unregisterReceiver(receiver)
Log.d(TAG, "다운로드 리시버 해제됨")
} catch (e: Exception) {
Log.w(TAG, "리시버 해제 실패 (이미 해제됨): ${e.message}")
Log.w(TAG, "리시버 해제 중 예외: ${e.message}")
}
registeredReceiver = null
}
}
/**
* 다운로드 상태 확인 (suspend 함수)
*/
suspend fun getDownloadStatus(context: Context, downloadId: Long): DownloadStatus =
withContext(Dispatchers.IO) {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
@@ -161,8 +145,8 @@ object ApkDownloadManager {
val bytesTotalIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val downloadStatus = it.getInt(statusIndex)
val bytesDownloaded = it.getLong(bytesDownloadedIndex)
val bytesTotal = it.getLong(bytesTotalIndex)
val bytesDownloaded = if (bytesDownloadedIndex >= 0) it.getLong(bytesDownloadedIndex) else 0L
val bytesTotal = if (bytesTotalIndex >= 0) it.getLong(bytesTotalIndex) else 0L
val progress = if (bytesTotal > 0) {
((bytesDownloaded * 100) / bytesTotal).toInt()
@@ -181,26 +165,27 @@ object ApkDownloadManager {
}
/**
* 다운로드 완료 대기 (suspend 함수)
*/
suspend fun waitForDownload(context: Context, downloadId: Long): Boolean {
while (true) {
val status = getDownloadStatus(context, downloadId)
if (status.isComplete) return true
if (status.isFailed) return false
delay(500)
}
}
/**
* APK 파일 설치
* @return Boolean true if installation started, false if permission denied
* APK 파일 설치 화면 실행
*/
fun installApk(context: Context): Boolean {
// Android 8.0 이상에서 알 수 없는 앱 설치 권한 확인
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (!context.packageManager.canRequestPackageInstalls()) {
Log.w(TAG, "앱 설치 권한 없음")
Toast.makeText(context, "설치 권한이 필요합니다. 설정에서 허용해주세요.", Toast.LENGTH_SHORT).show()
Log.w(TAG, "앱 설치 권한 없음 -> 설정 화면 이동")
Toast.makeText(context, "설치 권한을 허용한 후 다시 시도해주세요.", Toast.LENGTH_LONG).show()
try {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
return false
}
}
@@ -208,7 +193,8 @@ object ApkDownloadManager {
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), APK_FILE_NAME)
if (!apkFile.exists()) {
Toast.makeText(context, "APK 파일을 찾을 수 없습니다", Toast.LENGTH_SHORT).show()
Log.e(TAG, "APK 파일이 존재하지 않음: ${apkFile.absolutePath}")
Toast.makeText(context, "APK 파일을 찾을 수 없습니다. 다시 다운로드해주세요.", Toast.LENGTH_SHORT).show()
return false
}
@@ -219,23 +205,23 @@ object ApkDownloadManager {
apkFile
)
Log.d(TAG, "설치 인텐트 시작: uri=$apkUri")
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(apkUri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
context.startActivity(intent)
return true
} catch (e: Exception) {
Toast.makeText(context, "설치를 시작할 수 없습니다: ${e.message}", Toast.LENGTH_SHORT).show()
Log.e(TAG, "설치 화면 호출 실패", e)
Toast.makeText(context, "설치 화면을 열 수 없습니다: ${e.message}", Toast.LENGTH_LONG).show()
return false
}
}
/**
* 다운로드 상태 데이터 클래스
*/
data class DownloadStatus(
val progress: Int,
val bytesDownloaded: Long,
+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.9",
"versionCode": 29,
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases/download/v0.2.9/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) 핫딜 채널 추가"
"사이트 및 키워드 순서 조절을 터치 드래그 앤 드롭(Drag & Drop) 방식으로 전면 업그레이드",
"앱 내 업데이트 시 다운로드 완료 후 설치 화면으로 안 넘어가는 버그 수정",
"새 버전 업데이트 안내 다이얼로그 및 진행률 프로그레스 바 연동"
]
}