From e06e5b744a9669991b3690c88d89c053337efe62 Mon Sep 17 00:00:00 2001 From: sanjeok77 <1+sanjeok77@noreply.localhost> Date: Thu, 20 Aug 2026 23:59:48 +0000 Subject: [PATCH] v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug --- .../presentation/settings/SettingsScreen.kt | 372 +++++++++++------- 1 file changed, 240 insertions(+), 132 deletions(-) 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 0819060..9cf63b7 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 @@ -14,7 +14,7 @@ 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.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape @@ -40,19 +40,21 @@ import com.hotdeal.alarm.presentation.components.* import com.hotdeal.alarm.presentation.main.MainUiState import com.hotdeal.alarm.presentation.main.MainViewModel import com.hotdeal.alarm.ui.theme.* +import com.hotdeal.alarm.util.ApkDownloadManager import com.hotdeal.alarm.util.PermissionHelper +import com.hotdeal.alarm.util.UpdateInfo import com.hotdeal.alarm.util.VersionManager +import kotlinx.coroutines.delay import kotlinx.coroutines.launch /** * One UI 9 & Material 3 Expressive Bento Settings Screen - * 순서 변경(Reordering) 및 Bento 그리드 구조를 지원하는 프리미엄 설정 화면 + * 완벽한 터치 드래그 앤 드롭(Drag & Drop) 및 OTA 자동 설치를 지원하는 설정 화면 */ @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(viewModel: MainViewModel) { val context = LocalContext.current - val uiState by viewModel.uiState.collectAsState() val scope = rememberCoroutineScope() val currentPollingInterval by viewModel.pollingInterval.collectAsState() @@ -167,7 +169,7 @@ fun SettingsScreen(viewModel: MainViewModel) { } // ============================================ -// 탭 1: 알림 & 키워드 (NotificationTab) +// 탭 1: 알림 & 키워드 (NotificationTab - 드래그앤드롭 Reordering) // ============================================ @Composable private fun NotificationTab( @@ -183,14 +185,30 @@ private fun NotificationTab( 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 = 14.dp), + .padding(horizontal = 14.dp) + .dragDropGesture(dragDropState), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp) ) { - // Now Status Bento 2x2 카드 + // 0: Now Status Bento 2x2 카드 item { SquircleCard( containerColor = MaterialTheme.colorScheme.surface, @@ -255,7 +273,7 @@ private fun NotificationTab( } } - // 모듈형 수집 주기 선택 블록 + // 1: 모듈형 수집 주기 선택 블록 item { SquircleCard( containerColor = MaterialTheme.colorScheme.surface, @@ -325,7 +343,7 @@ private fun NotificationTab( } } - // 키워드 등록 입력창 + // 2: 키워드 등록 입력창 item { SquircleCard( containerColor = MaterialTheme.colorScheme.surface, @@ -408,7 +426,7 @@ private fun NotificationTab( } } - // 등록된 키워드 목록 (순서 조절 가능) + // 등록된 키워드 목록 (드래그 앤 드롭 Reordering) if (activeKeywords.isNotEmpty()) { item { Row( @@ -423,11 +441,22 @@ private fun NotificationTab( fontWeight = FontWeight.Bold ) Spacer(modifier = Modifier.weight(1f)) - Text( - text = "▲▼ 버튼으로 순서 조절", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f) - ) + 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) + ) + } } } @@ -436,12 +465,11 @@ private fun NotificationTab( key = { index -> activeKeywords[index].id } ) { index -> val keyword = activeKeywords[index] + val itemIndex = index + 4 // 헤더 3개 + 서브헤더 1개 + OneUIKeywordTile( keyword = keyword, - isFirst = index == 0, - isLast = index == activeKeywords.lastIndex, - onMoveUp = { viewModel.reorderKeywords(index, index - 1) }, - onMoveDown = { viewModel.reorderKeywords(index, index + 1) }, + modifier = Modifier.dragDropItem(itemIndex, dragDropState), onToggle = { viewModel.toggleKeyword(keyword.id, !keyword.isEnabled) }, onDelete = { viewModel.deleteKeyword(keyword.id) } ) @@ -451,17 +479,31 @@ private fun NotificationTab( } // ============================================ -// 탭 2: 사이트 관리 (SitesTab - 순서 조절 지원) +// 탭 2: 사이트 관리 (SitesTab - 완벽한 드래그앤드롭) // ============================================ @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) + } + } + ) + LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() - .padding(horizontal = 14.dp), + .padding(horizontal = 14.dp) + .dragDropGesture(dragDropState), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(top = 6.dp, bottom = 24.dp) ) { @@ -473,7 +515,7 @@ private fun SitesTab(viewModel: MainViewModel) { verticalAlignment = Alignment.CenterVertically ) { Icon( - imageVector = Icons.Outlined.SwapVert, + imageVector = Icons.Outlined.DragIndicator, contentDescription = null, tint = MaterialTheme.colorScheme.primary, modifier = Modifier.size(18.dp) @@ -481,12 +523,12 @@ private fun SitesTab(viewModel: MainViewModel) { Spacer(modifier = Modifier.width(6.dp)) Column(modifier = Modifier.weight(1f)) { Text( - text = "수집 대상 커뮤니티 및 우선순위", + text = "수집 대상 커뮤니티 순서 조절", style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold ) Text( - text = "▲▼ 버튼으로 순서를 변경하면 메인 필터 칩에 즉시 반영됩니다.", + text = "카드를 길게 눌러 위아래로 끌어놓으면 메인 필터에 즉시 반영됩니다.", style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -502,13 +544,12 @@ private fun SitesTab(viewModel: MainViewModel) { ) { index -> val site = siteOrder[index] val configs = successState.siteConfigs.filter { it.siteName == site.name } + val itemIndex = index + 1 + OneUISiteBentoCard( siteType = site, configs = configs, - isFirst = index == 0, - isLast = index == siteOrder.lastIndex, - onMoveUp = { viewModel.reorderSites(index, index - 1) }, - onMoveDown = { viewModel.reorderSites(index, index + 1) }, + modifier = Modifier.dragDropItem(itemIndex, dragDropState), onToggle = { key, enabled -> viewModel.toggleSiteConfig(key, enabled) } ) } @@ -528,16 +569,22 @@ 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) } - val toastEvent by viewModel.toastEvent.collectAsStateWithLifecycle(initialValue = null) + var isCheckingUpdate by remember { mutableStateOf(false) } + var availableUpdateInfo by remember { mutableStateOf(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 -> @@ -597,36 +644,55 @@ private fun MoreTab(viewModel: MainViewModel) { Spacer(modifier = Modifier.height(14.dp)) - Button( - onClick = { - scope.launch { - isCheckingUpdate = true - val currentCode = VersionManager.getCurrentVersionCode(context) - val remoteInfo = VersionManager.checkForUpdate() - isCheckingUpdate = false + 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 { + isCheckingUpdate = true + val currentCode = VersionManager.getCurrentVersionCode(context) + val remoteInfo = VersionManager.checkForUpdate() + isCheckingUpdate = false - if (remoteInfo != null && VersionManager.isUpdateAvailable(currentCode, remoteInfo.versionCode)) { - Toast.makeText(context, "새 버전(${remoteInfo.version})이 있습니다!", Toast.LENGTH_LONG).show() - } else { - Toast.makeText(context, "최신 버전을 사용 중입니다", Toast.LENGTH_SHORT).show() + if (remoteInfo != null && VersionManager.isUpdateAvailable(currentCode, remoteInfo.versionCode)) { + availableUpdateInfo = remoteInfo + showUpdateDialog = true + } else { + Toast.makeText(context, "최신 버전을 사용 중입니다 (v${VersionManager.getCurrentVersion(context)})", Toast.LENGTH_SHORT).show() + } } - } - }, - shape = CornerRadius.shapeNormal, - modifier = Modifier.height(42.dp), - enabled = !isCheckingUpdate - ) { - Icon( - imageVector = Icons.Outlined.SystemUpdate, - contentDescription = null, - modifier = Modifier.size(16.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text( - text = if (isCheckingUpdate) "확인 중..." else "업데이트 확인", - fontWeight = FontWeight.SemiBold, - fontSize = 13.sp - ) + }, + shape = CornerRadius.shapeNormal, + modifier = Modifier.height(42.dp), + enabled = !isCheckingUpdate + ) { + Icon( + imageVector = Icons.Outlined.SystemUpdate, + contentDescription = null, + modifier = Modifier.size(16.dp) + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = if (isCheckingUpdate) "확인 중..." else "업데이트 확인", + fontWeight = FontWeight.SemiBold, + fontSize = 13.sp + ) + } } } } @@ -687,6 +753,102 @@ private fun MoreTab(viewModel: MainViewModel) { } } + // 새 버전 업데이트 발견 다이얼로그 (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("새 버전 업데이트 (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) { AlertDialog( onDismissRequest = { showDeleteDialog = false }, @@ -713,7 +875,7 @@ private fun MoreTab(viewModel: MainViewModel) { } // ============================================ -// 서브 컴포넌트들 (순서 조절 버튼 포함) +// 서브 컴포넌트들 (드래그 핸들러 포함) // ============================================ @Composable @@ -762,56 +924,29 @@ private fun BentoMiniTile( @Composable private fun OneUIKeywordTile( keyword: Keyword, - isFirst: Boolean, - isLast: Boolean, - onMoveUp: () -> Unit, - onMoveDown: () -> Unit, + 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 = 12.dp, vertical = 8.dp), + .padding(horizontal = 12.dp, vertical = 10.dp), verticalAlignment = Alignment.CenterVertically ) { - // 순서 조절 상/하 버튼 - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - IconButton( - onClick = onMoveUp, - enabled = !isFirst, - modifier = Modifier.size(22.dp) - ) { - Icon( - imageVector = Icons.Filled.KeyboardArrowUp, - contentDescription = "위로 이동", - tint = if (!isFirst) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), - modifier = Modifier.size(16.dp) - ) - } - IconButton( - onClick = onMoveDown, - enabled = !isLast, - modifier = Modifier.size(22.dp) - ) { - Icon( - imageVector = Icons.Filled.KeyboardArrowDown, - contentDescription = "아래로 이동", - tint = if (!isLast) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), - modifier = Modifier.size(16.dp) - ) - } - } + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = "드래그하여 순서 변경", + tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f), + modifier = Modifier.size(20.dp) + ) - Spacer(modifier = Modifier.width(6.dp)) + Spacer(modifier = Modifier.width(8.dp)) Box( modifier = Modifier @@ -854,10 +989,7 @@ private fun OneUIKeywordTile( private fun OneUISiteBentoCard( siteType: SiteType, configs: List, - isFirst: Boolean, - isLast: Boolean, - onMoveUp: () -> Unit, - onMoveDown: () -> Unit, + modifier: Modifier = Modifier, onToggle: (String, Boolean) -> Unit ) { val siteColor = getSiteColor(siteType) @@ -869,7 +1001,7 @@ private fun OneUISiteBentoCard( 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(12.dp)) { // 상단 마스터 행 @@ -877,38 +1009,14 @@ private fun OneUISiteBentoCard( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically ) { - // 순서 조절 버튼 - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - IconButton( - onClick = onMoveUp, - enabled = !isFirst, - modifier = Modifier.size(22.dp) - ) { - Icon( - imageVector = Icons.Filled.KeyboardArrowUp, - contentDescription = "위로 이동", - tint = if (!isFirst) siteColor else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), - modifier = Modifier.size(16.dp) - ) - } - IconButton( - onClick = onMoveDown, - enabled = !isLast, - modifier = Modifier.size(22.dp) - ) { - Icon( - imageVector = Icons.Filled.KeyboardArrowDown, - contentDescription = "아래로 이동", - tint = if (!isLast) siteColor else MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), - modifier = Modifier.size(16.dp) - ) - } - } + Icon( + imageVector = Icons.Default.DragHandle, + contentDescription = "드래그하여 순서 변경", + tint = MaterialTheme.colorScheme.outline.copy(alpha = 0.55f), + modifier = Modifier.size(20.dp) + ) - Spacer(modifier = Modifier.width(6.dp)) + Spacer(modifier = Modifier.width(8.dp)) // 브랜드 뱃지 Surface(