diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt b/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt index 9cf63b7..4a48095 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/settings/SettingsScreen.kt @@ -49,7 +49,6 @@ import kotlinx.coroutines.launch /** * One UI 9 & Material 3 Expressive Bento Settings Screen - * 완벽한 터치 드래그 앤 드롭(Drag & Drop) 및 OTA 자동 설치를 지원하는 설정 화면 */ @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -84,9 +83,6 @@ fun SettingsScreen(viewModel: MainViewModel) { .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { - // ============================================ - // 1. One UI 9 Segmented Pill Tab Bar - // ============================================ Surface( modifier = Modifier .fillMaxWidth() @@ -133,9 +129,6 @@ fun SettingsScreen(viewModel: MainViewModel) { } } - // ============================================ - // 2. 탭 컨텐츠 (Horizontal Pager) - // ============================================ HorizontalPager( state = pagerState, modifier = Modifier.fillMaxSize(), @@ -169,8 +162,9 @@ fun SettingsScreen(viewModel: MainViewModel) { } // ============================================ -// 탭 1: 알림 & 키워드 (NotificationTab - 드래그앤드롭 Reordering) +// 탭 1: 알림 & 키워드 (NotificationTab - Key 기반 드래그앤드롭) // ============================================ +@OptIn(ExperimentalFoundationApi::class) @Composable private fun NotificationTab( viewModel: MainViewModel, @@ -186,15 +180,16 @@ private fun NotificationTab( 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) + val reorderState = rememberReorderState( + listState = listState, + onSwap = { fromListIdx, toListIdx -> + val kwFrom = fromListIdx - 4 // 헤더 3개 + 서브헤더 1개 + val kwTo = toListIdx - 4 + if (kwFrom in activeKeywords.indices && kwTo in activeKeywords.indices) { + viewModel.reorderKeywords(kwFrom, kwTo) + true + } else { + false } } ) @@ -203,13 +198,11 @@ private fun NotificationTab( state = listState, modifier = Modifier .fillMaxSize() - .padding(horizontal = 14.dp) - .dragDropGesture(dragDropState), + .padding(horizontal = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp) ) { - // 0: Now Status Bento 2x2 카드 - item { + item(key = "__bento_status__") { SquircleCard( containerColor = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth() @@ -219,25 +212,11 @@ private fun NotificationTab( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp) ) { - Icon( - imageVector = Icons.Outlined.Dashboard, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(17.dp) - ) - Text( - text = "실시간 모니터링 요약", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) + Icon(Icons.Outlined.Dashboard, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(17.dp)) + Text("실시간 모니터링 요약", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) } - Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { BentoMiniTile( title = "알림 상태", value = if (permissionStatus.hasNotificationPermission) "정상 가동" else "권한 필요", @@ -245,83 +224,35 @@ private fun NotificationTab( modifier = Modifier.weight(1f), onClick = if (!permissionStatus.hasNotificationPermission) onRequestPermission else null ) - BentoMiniTile( - title = "감시 주기", - value = "${currentPollingInterval}분 간격", - modifier = Modifier.weight(1f) - ) + BentoMiniTile(title = "감시 주기", value = "${currentPollingInterval}분 간격", modifier = Modifier.weight(1f)) } - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - BentoMiniTile( - title = "활성 사이트", - value = "${activeSitesCount}개 선택됨", - modifier = Modifier.weight(1f) - ) - BentoMiniTile( - title = "등록 키워드", - value = "${activeKeywords.size}개 등록됨", - modifier = Modifier.weight(1f) - ) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + BentoMiniTile(title = "활성 사이트", value = "${activeSitesCount}개 선택됨", modifier = Modifier.weight(1f)) + BentoMiniTile(title = "등록 키워드", value = "${activeKeywords.size}개 등록됨", modifier = Modifier.weight(1f)) } } } } - // 1: 모듈형 수집 주기 선택 블록 - item { - SquircleCard( - containerColor = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxWidth() - ) { + item(key = "__polling_interval__") { + SquircleCard(containerColor = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(14.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Outlined.Timer, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(17.dp) - ) - Text( - text = "수집 주기 설정", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.Timer, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(17.dp)) + Text("수집 주기 설정", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "백그라운드에서 새로운 핫딜을 확인하는 주기입니다.", - style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.5.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - + Text("백그라운드에서 새로운 핫딜을 확인하는 주기입니다.", style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.5.sp), color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(modifier = Modifier.height(10.dp)) - val intervals = listOf(1, 2, 5, 10, 30) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(5.dp) - ) { + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(5.dp)) { intervals.forEach { minutes -> val isSelected = currentPollingInterval == minutes val bgColor = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) val textColor = if (isSelected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant - Box( - modifier = Modifier - .weight(1f) - .height(34.dp) - .clip(CornerRadius.shapeSmall) - .background(bgColor) + modifier = Modifier.weight(1f).height(34.dp).clip(CornerRadius.shapeSmall).background(bgColor) .elasticPressClickable { viewModel.stopPolling() viewModel.startPolling(minutes.toLong()) @@ -329,13 +260,7 @@ private fun NotificationTab( }, contentAlignment = Alignment.Center ) { - Text( - text = "${minutes}분", - style = MaterialTheme.typography.labelSmall.copy( - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium - ), - color = textColor - ) + Text("${minutes}분", style = MaterialTheme.typography.labelSmall.copy(fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium), color = textColor) } } } @@ -343,57 +268,21 @@ private fun NotificationTab( } } - // 2: 키워드 등록 입력창 - item { - SquircleCard( - containerColor = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxWidth() - ) { + item(key = "__keyword_input__") { + SquircleCard(containerColor = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(14.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Outlined.NotificationsActive, - contentDescription = null, - tint = KeywordGold, - modifier = Modifier.size(17.dp) - ) - Text( - text = "키워드 등록 & 관리", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.NotificationsActive, null, tint = KeywordGold, modifier = Modifier.size(17.dp)) + Text("키워드 등록 & 관리", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "키워드가 포함된 핫딜 수집 시 즉시 알림을 발송합니다.", - style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.5.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - + Text("키워드가 포함된 핫딜 수집 시 즉시 알림을 발송합니다.", style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.5.sp), color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(modifier = Modifier.height(10.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { OutlinedTextField( - value = keywordInput, - onValueChange = { keywordInput = it }, - placeholder = { - Text( - text = "키워드 입력 (예: 모니터, 그래픽카드)", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) - ) - }, - singleLine = true, - shape = CornerRadius.shapeNormal, - modifier = Modifier.weight(1f), + value = keywordInput, onValueChange = { keywordInput = it }, + placeholder = { Text("키워드 입력 (예: 모니터, 그래픽카드)", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)) }, + singleLine = true, shape = CornerRadius.shapeNormal, modifier = Modifier.weight(1f), colors = OutlinedTextFieldDefaults.colors( focusedContainerColor = MaterialTheme.colorScheme.surface, unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f), @@ -401,61 +290,30 @@ private fun NotificationTab( unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f) ) ) - Button( - onClick = { - if (keywordInput.isNotBlank()) { - viewModel.addKeyword(keywordInput.trim()) - keywordInput = "" - } - }, - shape = CornerRadius.shapeNormal, - modifier = Modifier.height(50.dp), - enabled = keywordInput.isNotBlank() + onClick = { if (keywordInput.isNotBlank()) { viewModel.addKeyword(keywordInput.trim()); keywordInput = "" } }, + shape = CornerRadius.shapeNormal, modifier = Modifier.height(50.dp), enabled = keywordInput.isNotBlank() ) { - Icon( - imageVector = Icons.Filled.Add, - contentDescription = "추가", - modifier = Modifier.size(17.dp) - ) + Icon(Icons.Filled.Add, "추가", modifier = Modifier.size(17.dp)) Spacer(modifier = Modifier.width(3.dp)) - Text(text = "추가", fontWeight = FontWeight.Bold, fontSize = 13.sp) + Text("추가", fontWeight = FontWeight.Bold, fontSize = 13.sp) } } } } } - // 등록된 키워드 목록 (드래그 앤 드롭 Reordering) if (activeKeywords.isNotEmpty()) { - item { + item(key = "__keyword_header__") { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 2.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = "등록된 키워드 (${activeKeywords.size}개)", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) + Text("등록된 키워드 (${activeKeywords.size}개)", style = MaterialTheme.typography.titleSmall, 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) - ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon(Icons.Default.DragHandle, null, tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), modifier = Modifier.size(14.dp)) + Text("길게 눌러 드래그", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)) } } } @@ -465,11 +323,13 @@ private fun NotificationTab( key = { index -> activeKeywords[index].id } ) { index -> val keyword = activeKeywords[index] - val itemIndex = index + 4 // 헤더 3개 + 서브헤더 1개 + val isDragged = reorderState.draggedKey == keyword.id OneUIKeywordTile( keyword = keyword, - modifier = Modifier.dragDropItem(itemIndex, dragDropState), + modifier = Modifier + .reorderableItem(reorderState, key = keyword.id) + .then(if (!isDragged) Modifier.animateItemPlacement() else Modifier), onToggle = { viewModel.toggleKeyword(keyword.id, !keyword.isEnabled) }, onDelete = { viewModel.deleteKeyword(keyword.id) } ) @@ -479,21 +339,25 @@ private fun NotificationTab( } // ============================================ -// 탭 2: 사이트 관리 (SitesTab - 완벽한 드래그앤드롭) +// 탭 2: 사이트 관리 (SitesTab - Key 기반 드래그앤드롭) // ============================================ +@OptIn(ExperimentalFoundationApi::class) @Composable private fun SitesTab(viewModel: MainViewModel) { 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) + val reorderState = rememberReorderState( + listState = listState, + onSwap = { fromListIdx, toListIdx -> + val siteFrom = fromListIdx - 1 // 헤더 1개 + val siteTo = toListIdx - 1 + if (siteFrom in siteOrder.indices && siteTo in siteOrder.indices) { + viewModel.reorderSites(siteFrom, siteTo) + true + } else { + false } } ) @@ -502,36 +366,20 @@ private fun SitesTab(viewModel: MainViewModel) { state = listState, modifier = Modifier .fillMaxSize() - .padding(horizontal = 14.dp) - .dragDropGesture(dragDropState), + .padding(horizontal = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp) ) { - item { + item(key = "__site_header__") { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp, vertical = 2.dp), + modifier = Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 2.dp), verticalAlignment = Alignment.CenterVertically ) { - Icon( - imageVector = Icons.Outlined.DragIndicator, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(18.dp) - ) + Icon(Icons.Outlined.SwapVert, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp)) Spacer(modifier = Modifier.width(6.dp)) Column(modifier = Modifier.weight(1f)) { - Text( - text = "수집 대상 커뮤니티 순서 조절", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - Text( - text = "카드를 길게 눌러 위아래로 끌어놓으면 메인 필터에 즉시 반영됩니다.", - style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Text("수집 대상 커뮤니티 순서 조절", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) + Text("카드를 길게 눌러 위아래로 끌어놓으면 메인 필터에 즉시 반영됩니다.", style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), color = MaterialTheme.colorScheme.onSurfaceVariant) } } } @@ -544,23 +392,20 @@ private fun SitesTab(viewModel: MainViewModel) { ) { index -> val site = siteOrder[index] val configs = successState.siteConfigs.filter { it.siteName == site.name } - val itemIndex = index + 1 + val isDragged = reorderState.draggedKey == site.name OneUISiteBentoCard( siteType = site, configs = configs, - modifier = Modifier.dragDropItem(itemIndex, dragDropState), + modifier = Modifier + .reorderableItem(reorderState, key = site.name) + .then(if (!isDragged) Modifier.animateItemPlacement() else Modifier), onToggle = { key, enabled -> viewModel.toggleSiteConfig(key, enabled) } ) } } else { item { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - contentAlignment = Alignment.Center - ) { + Box(modifier = Modifier.fillMaxWidth().padding(32.dp), contentAlignment = Alignment.Center) { CircularProgressIndicator() } } @@ -569,7 +414,7 @@ private fun SitesTab(viewModel: MainViewModel) { } // ============================================ -// 탭 3: 데이터 & 정보 (MoreTab - 자동 다운로드 & 설치 화면 호출) +// 탭 3: 데이터 & 정보 (MoreTab) // ============================================ @Composable private fun MoreTab(viewModel: MainViewModel) { @@ -593,72 +438,30 @@ private fun MoreTab(viewModel: MainViewModel) { } LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(horizontal = 14.dp), + modifier = Modifier.fillMaxSize().padding(horizontal = 14.dp), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp) ) { - // App Hero Info Card item { - SquircleCard( - containerColor = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxWidth() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(18.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { + SquircleCard(containerColor = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth().padding(18.dp), horizontalAlignment = Alignment.CenterHorizontally) { Box( - modifier = Modifier - .size(56.dp) - .clip(CornerRadius.shapeSquircle) - .background(MaterialTheme.colorScheme.primaryContainer), + modifier = Modifier.size(56.dp).clip(CornerRadius.shapeSquircle).background(MaterialTheme.colorScheme.primaryContainer), contentAlignment = Alignment.Center ) { - Icon( - imageVector = Icons.Filled.LocalFireDepartment, - contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(32.dp) - ) + Icon(Icons.Filled.LocalFireDepartment, null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(32.dp)) } - Spacer(modifier = Modifier.height(10.dp)) - - Text( - text = "핫딜 알람 (HotDeal Alarm)", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - + Text("핫딜 알람 (HotDeal Alarm)", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Spacer(modifier = Modifier.height(2.dp)) - - Text( - text = "v${VersionManager.getCurrentVersion(context)} • One UI 9 Edition", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - + Text("v${VersionManager.getCurrentVersion(context)} • One UI 9 Edition", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) 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) - ) + 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 - ) + Text("업데이트 다운로드 중... ($downloadProgress%)", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary) } } else { Button( @@ -668,7 +471,6 @@ private fun MoreTab(viewModel: MainViewModel) { val currentCode = VersionManager.getCurrentVersionCode(context) val remoteInfo = VersionManager.checkForUpdate() isCheckingUpdate = false - if (remoteInfo != null && VersionManager.isUpdateAvailable(currentCode, remoteInfo.versionCode)) { availableUpdateInfo = remoteInfo showUpdateDialog = true @@ -677,175 +479,88 @@ private fun MoreTab(viewModel: MainViewModel) { } } }, - shape = CornerRadius.shapeNormal, - modifier = Modifier.height(42.dp), - enabled = !isCheckingUpdate + shape = CornerRadius.shapeNormal, modifier = Modifier.height(42.dp), enabled = !isCheckingUpdate ) { - Icon( - imageVector = Icons.Outlined.SystemUpdate, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) + Icon(Icons.Outlined.SystemUpdate, null, modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.width(6.dp)) - Text( - text = if (isCheckingUpdate) "확인 중..." else "업데이트 확인", - fontWeight = FontWeight.SemiBold, - fontSize = 13.sp - ) + Text(if (isCheckingUpdate) "확인 중..." else "업데이트 확인", fontWeight = FontWeight.SemiBold, fontSize = 13.sp) } } } } } - // 데이터 관리 Bento 카드 item { - SquircleCard( - containerColor = MaterialTheme.colorScheme.surface, - modifier = Modifier.fillMaxWidth() - ) { + SquircleCard(containerColor = MaterialTheme.colorScheme.surface, modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.padding(14.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - imageVector = Icons.Outlined.Storage, - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(17.dp) - ) - Text( - text = "데이터 및 캐시 관리", - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Outlined.Storage, null, tint = MaterialTheme.colorScheme.error, modifier = Modifier.size(17.dp)) + Text("데이터 및 캐시 관리", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold) } - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = "수집되어 로컬 DB에 캐싱된 모든 핫딜 데이터를 일괄 삭제합니다.", - style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.5.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - + Text("수집되어 로컬 DB에 캐싱된 모든 핫딜 데이터를 일괄 삭제합니다.", style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.5.sp), color = MaterialTheme.colorScheme.onSurfaceVariant) Spacer(modifier = Modifier.height(12.dp)) - FilledTonalButton( - onClick = { showDeleteDialog = true }, - shape = CornerRadius.shapeNormal, - colors = ButtonDefaults.filledTonalButtonColors( - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ), + onClick = { showDeleteDialog = true }, shape = CornerRadius.shapeNormal, + colors = ButtonDefaults.filledTonalButtonColors(containerColor = MaterialTheme.colorScheme.errorContainer, contentColor = MaterialTheme.colorScheme.onErrorContainer), modifier = Modifier.fillMaxWidth().height(44.dp) ) { - Icon( - imageVector = Icons.Outlined.DeleteSweep, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) + Icon(Icons.Outlined.DeleteSweep, null, modifier = Modifier.size(18.dp)) Spacer(modifier = Modifier.width(6.dp)) - Text(text = "수집 데이터 전체 비우기", fontWeight = FontWeight.Bold, fontSize = 13.sp) + 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 - ) + Icon(Icons.Filled.NewReleases, null, tint = MaterialTheme.colorScheme.primary) Spacer(modifier = Modifier.width(8.dp)) Text("새 버전 업데이트 (v${info.version})") } }, text = { Column { - Text( - text = "최신 버전으로 업데이트할 수 있습니다.", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.SemiBold - ) + 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 - ) + 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 - ) + 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 - } - } + Button(onClick = { + showUpdateDialog = false + isDownloading = true + downloadProgress = 0 + val downloadId = ApkDownloadManager.downloadApk(context, info) + ApkDownloadManager.registerDownloadCompleteReceiver( + context = context, downloadId = downloadId, + onComplete = { isDownloading = false; downloadProgress = 100; ApkDownloadManager.installApk(context) }, + onFailed = { isDownloading = false; Toast.makeText(context, "다운로드에 실패했습니다.", Toast.LENGTH_SHORT).show() } + ) + 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("지금 업데이트 & 설치") - } + }) { Text("지금 업데이트 & 설치") } }, - dismissButton = { - TextButton(onClick = { showUpdateDialog = false }) { - Text("나중에") - } - } + dismissButton = { TextButton(onClick = { showUpdateDialog = false }) { Text("나중에") } } ) } @@ -856,77 +571,43 @@ private fun MoreTab(viewModel: MainViewModel) { text = { Text("저장된 모든 핫딜 데이터를 삭제하시겠습니까? (설정 및 키워드는 유지됩니다)") }, confirmButton = { Button( - onClick = { - viewModel.deleteAllParsedData() - showDeleteDialog = false - }, + onClick = { viewModel.deleteAllParsedData(); showDeleteDialog = false }, colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.error) - ) { - Text("삭제") - } + ) { Text("삭제") } }, - dismissButton = { - TextButton(onClick = { showDeleteDialog = false }) { - Text("취소") - } - } + dismissButton = { TextButton(onClick = { showDeleteDialog = false }) { Text("취소") } } ) } } // ============================================ -// 서브 컴포넌트들 (드래그 핸들러 포함) +// 서브 컴포넌트들 // ============================================ @Composable private fun BentoMiniTile( - title: String, - value: String, - isHighlight: Boolean = false, - modifier: Modifier = Modifier, - onClick: (() -> Unit)? = null + title: String, value: String, isHighlight: Boolean = false, + modifier: Modifier = Modifier, onClick: (() -> Unit)? = null ) { - val bgColor = if (isHighlight) { - MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) - } - - val textColor = if (isHighlight) { - MaterialTheme.colorScheme.error - } else { - MaterialTheme.colorScheme.primary - } - + val bgColor = if (isHighlight) MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + val textColor = if (isHighlight) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary Box( - modifier = modifier - .clip(CornerRadius.shapeNormal) - .background(bgColor) + modifier = modifier.clip(CornerRadius.shapeNormal).background(bgColor) .then(if (onClick != null) Modifier.elasticPressClickable(onClick = onClick) else Modifier) .padding(horizontal = 10.dp, vertical = 8.dp) ) { Column { - Text( - text = title, - style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.5.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) + Text(title, 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.labelMedium.copy(fontWeight = FontWeight.Bold, fontSize = 12.5.sp), - color = textColor - ) + Text(value, style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold, fontSize = 12.5.sp), color = textColor) } } } @Composable private fun OneUIKeywordTile( - keyword: Keyword, - modifier: Modifier = Modifier, - onToggle: () -> Unit, - onDelete: () -> Unit + keyword: Keyword, modifier: Modifier = Modifier, + onToggle: () -> Unit, onDelete: () -> Unit ) { SquircleCard( shape = CornerRadius.shapeNormal, @@ -934,52 +615,18 @@ private fun OneUIKeywordTile( modifier = modifier.fillMaxWidth() ) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), + modifier = Modifier.fillMaxWidth().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) - ) - + Icon(Icons.Default.DragHandle, "드래그하여 순서 변경", tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f), modifier = Modifier.size(20.dp)) Spacer(modifier = Modifier.width(8.dp)) - - Box( - modifier = Modifier - .size(7.dp) - .background(KeywordGold, CircleShape) - ) - + Box(modifier = Modifier.size(7.dp).background(KeywordGold, CircleShape)) Spacer(modifier = Modifier.width(8.dp)) - - Text( - text = keyword.keyword, - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), - modifier = Modifier.weight(1f) - ) - - Switch( - checked = keyword.isEnabled, - onCheckedChange = { onToggle() }, - modifier = Modifier.scale(0.8f) - ) - + Text(keyword.keyword, style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.SemiBold), modifier = Modifier.weight(1f)) + Switch(checked = keyword.isEnabled, onCheckedChange = { onToggle() }, modifier = Modifier.scale(0.8f)) Spacer(modifier = Modifier.width(2.dp)) - - IconButton( - onClick = onDelete, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = "삭제", - tint = MaterialTheme.colorScheme.outline, - modifier = Modifier.size(16.dp) - ) + IconButton(onClick = onDelete, modifier = Modifier.size(28.dp)) { + Icon(Icons.Outlined.Delete, "삭제", tint = MaterialTheme.colorScheme.outline, modifier = Modifier.size(16.dp)) } } } @@ -987,129 +634,50 @@ private fun OneUIKeywordTile( @Composable private fun OneUISiteBentoCard( - siteType: SiteType, - configs: List, - modifier: Modifier = Modifier, - onToggle: (String, Boolean) -> Unit + siteType: SiteType, configs: List, + modifier: Modifier = Modifier, onToggle: (String, Boolean) -> Unit ) { val siteColor = getSiteColor(siteType) val isAnyEnabled = configs.any { it.isEnabled } var isExpanded by remember { mutableStateOf(false) } SquircleCard( - shape = CornerRadius.shapeNormal, - containerColor = MaterialTheme.colorScheme.surface, + 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(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) - ) - + Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Icon(Icons.Default.DragHandle, "드래그하여 순서 변경", 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(32.dp) - ) { + Surface(shape = CornerRadius.shapeSmall, color = siteColor.copy(alpha = 0.14f), modifier = Modifier.size(32.dp)) { Box(contentAlignment = Alignment.Center) { - Text( - text = siteType.displayName.take(1), - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.ExtraBold), - color = siteColor - ) + Text(siteType.displayName.take(1), style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.ExtraBold), color = siteColor) } } - Spacer(modifier = Modifier.width(10.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = siteType.displayName, - style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), - color = MaterialTheme.colorScheme.onSurface - ) - Text( - text = "${configs.count { it.isEnabled }}/${configs.size}개 게시판 활성화", - style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + Text(siteType.displayName, style = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.onSurface) + Text("${configs.count { it.isEnabled }}/${configs.size}개 게시판 활성화", style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), color = MaterialTheme.colorScheme.onSurfaceVariant) } - - // 전체 온/오프 일괄 마스터 스위치 - Switch( - checked = isAnyEnabled, - onCheckedChange = { enableAll -> - configs.forEach { config -> - onToggle(config.siteBoardKey, enableAll) - } - }, - modifier = Modifier.scale(0.85f) - ) - - IconButton( - onClick = { isExpanded = !isExpanded }, - modifier = Modifier.size(28.dp) - ) { - Icon( - imageVector = if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, - contentDescription = "게시판 상세", - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) + Switch(checked = isAnyEnabled, onCheckedChange = { enableAll -> configs.forEach { config -> onToggle(config.siteBoardKey, enableAll) } }, modifier = Modifier.scale(0.85f)) + IconButton(onClick = { isExpanded = !isExpanded }, modifier = Modifier.size(28.dp)) { + Icon(if (isExpanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, "게시판 상세", tint = MaterialTheme.colorScheme.onSurfaceVariant) } } - // 아코디언 세부 게시판 목록 - AnimatedVisibility( - visible = isExpanded, - enter = expandVertically() + fadeIn(), - exit = shrinkVertically() + fadeOut() - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - ) { - HorizontalDivider( - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), - thickness = 0.8.dp - ) + AnimatedVisibility(visible = isExpanded, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { + Column(modifier = Modifier.fillMaxWidth().padding(top = 8.dp)) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f), thickness = 0.8.dp) Spacer(modifier = Modifier.height(6.dp)) - siteType.boards.forEach { board -> val config = configs.find { it.boardName == board.id } val isEnabled = config?.isEnabled == true val key = config?.siteBoardKey ?: "${siteType.name}_${board.id}" - - Row( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 3.dp, horizontal = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = board.displayName, - 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.75f) - ) + Row(modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp, horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically) { + Text(board.displayName, 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.75f)) } } }