From bf2084626ba665950028713392c7dde35cd0d605 Mon Sep 17 00:00:00 2001 From: sanjeok77 Date: Sat, 7 Mar 2026 03:37:54 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=ED=83=AD=20=EA=B5=AC=EC=A1=B0=ED=99=94=20=EB=B0=8F?= =?UTF-8?q?=20EdgeToEdge=20=EA=B0=9C=EC=84=A0=20(v1.8.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 설정 페이지를 3개 탭으로 구분 (알림, 사이트, 기타) - 탭 간 스와이프 이동 지원 - One UI 7+ EdgeToEdge 문제 해결 (시스템 바 침범 수정) - 파싱 데이터 삭제 기능 추가 - 업데이트 확인 시 즉시 다운로드 및 설치 기능 구현 --- app/build.gradle.kts | 5 +- .../alarm/presentation/main/MainActivity.kt | 119 +++- .../alarm/presentation/main/MainScreen.kt | 54 +- .../presentation/settings/SettingsScreen.kt | 572 ++++++++++++------ 4 files changed, 512 insertions(+), 238 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f77243..9800070 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,9 +24,8 @@ android { applicationId = "com.hotdeal.alarm" minSdk = 31 targetSdk = 35 - versionCode = 13 - versionName = "1.7.0" - versionName = "1.6.0" + versionCode = 14 + versionName = "1.8.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt b/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt index 554e08e..1fcf33e 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/main/MainActivity.kt @@ -2,6 +2,9 @@ package com.hotdeal.alarm.presentation.main import android.content.BroadcastReceiver import android.os.Bundle +import android.view.View +import android.view.WindowInsets +import android.view.WindowManager import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -11,6 +14,9 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import androidx.core.view.ViewCompat +import androidx.core.view.WindowCompat +import androidx.core.view.WindowInsetsCompat import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.lifecycleScope import com.hotdeal.alarm.ui.theme.HotDealTheme @@ -32,9 +38,11 @@ class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() - // 2분 간격으로 폴� 시작 + // EdgeToEdge 설정 (One UI 7+ 대응) + setupEdgeToEdge() + + // 2분 간격으로 폴링 시작 workerScheduler.schedulePeriodicPolling(2) setContent { @@ -67,8 +75,8 @@ class MainActivity : ComponentActivity() { updateInfo = updateInfo!!, isDownloading = isDownloading, downloadProgress = downloadProgress, - onDismiss = { - if (!isDownloading) showUpdateDialog = false + onDismiss = { + if (!isDownloading) showUpdateDialog = false }, onUpdate = { // APK 다이렉트 다운로드 @@ -105,11 +113,11 @@ class MainActivity : ComponentActivity() { downloadId ) downloadProgress = status.progress - + if (status.isComplete || status.isFailed) { break } - + kotlinx.coroutines.delay(500) } } @@ -121,30 +129,85 @@ class MainActivity : ComponentActivity() { } } - override fun onDestroy() { - super.onDestroy() - // 리시버 해제 - downloadReceiver?.let { - ApkDownloadManager.unregisterDownloadCompleteReceiver(this, it) - } - } + /** + * EdgeToEdge 설정 - One UI 7+ 대응 + */ + private fun setupEdgeToEdge() { + // WindowCompat을 사용한 설정 + WindowCompat.setDecorFitsSystemWindows(window, false) - // 비저빌리티 콜백 - 백그라운드에서 포어그라운드 전환 시 새로고침 - private var isInBackground = false + // statusBarColor와 navigationBarColor를 투명하게 + window.statusBarColor = android.graphics.Color.TRANSPARENT + window.navigationBarColor = android.graphics.Color.TRANSPARENT - override fun onResume() { - super.onResume() - if (isInBackground) { - // 백그라운드에서 돌아왔을 때 새로고침 - workerScheduler.executeOnce() - isInBackground = false - } - } + // One UI 7+에서 시스템 바가 콘텐츠를 가리지 않도록 설정 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { + window.setDecorFitsSystemWindows(false) - override fun onPause() { - super.onPause() - isInBackground = true - } + // 시스템 바 인셋 처리 + ViewCompat.setOnApplyWindowInsetsListener(window.decorView.rootView) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val ime = insets.getInsets(WindowInsetsCompat.Type.ime()) + + view.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) + insets + } + } else { + @Suppress("DEPRECATION") + window.setFlags( + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS, + WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS + ) + } + + // 시스템 바 아이콘 색상 설정 (라이트/다크 모드) + val decorView = window.decorView + @Suppress("DEPRECATION") + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.R) { + window.insetsController?.let { controller -> + // 상태바 아이콘 밝게 (다크 모드용) + controller.setSystemBarsAppearance( + 0, + android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + ) + // 네비게이션바 아이콘 밝게 (다크 모드용) + controller.setSystemBarsAppearance( + 0, + android.view.WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS + ) + } + } else { + @Suppress("DEPRECATION") + decorView.systemUiVisibility = decorView.systemUiVisibility or + View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR or + View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR + } + } + + override fun onDestroy() { + super.onDestroy() + // 리시버 해제 + downloadReceiver?.let { + ApkDownloadManager.unregisterDownloadCompleteReceiver(this, it) + } + } + + // 비저빌리티 콜백 - 백그라운드에서 포어그라운드 전환 시 새로고침 + private var isInBackground = false + + override fun onResume() { + super.onResume() + if (isInBackground) { + // 백그라운드에서 돌아왔을 때 새로고침 + workerScheduler.executeOnce() + isInBackground = false + } + } + + override fun onPause() { + super.onPause() + isInBackground = true + } /** * 업데이트 체크 @@ -198,7 +261,7 @@ fun UpdateDialog( updateInfo.changelog.take(3).forEach { change -> Text("• $change", style = MaterialTheme.typography.bodySmall) } - + if (isDownloading) { Spacer(modifier = Modifier.height(16.dp)) LinearProgressIndicator( diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/main/MainScreen.kt b/app/src/main/java/com/hotdeal/alarm/presentation/main/MainScreen.kt index 1422be8..9c0ef83 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/main/MainScreen.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/main/MainScreen.kt @@ -5,21 +5,25 @@ import android.content.pm.PackageManager import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.* +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material3.* import androidx.compose.runtime.* +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController -import androidx.navigation.compose.NavHost -import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController +import kotlinx.coroutines.launch import com.hotdeal.alarm.presentation.components.PermissionDialog import com.hotdeal.alarm.presentation.deallist.DealListScreen import com.hotdeal.alarm.presentation.settings.SettingsScreen +@OptIn(ExperimentalFoundationApi::class) @Composable fun MainScreen( viewModel: MainViewModel, @@ -49,19 +53,41 @@ fun MainScreen( } } - // TopAppBar 제거 - 화면을 넓게 사용 - NavHost( - navController = navController, - startDestination = Screen.DealList.route - ) { - composable(Screen.DealList.route) { - DealListScreen( - viewModel = viewModel, - onNavigateToSettings = { navController.navigate(Screen.Settings.route) } - ) + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { 2 } + ) + val coroutineScope = rememberCoroutineScope() + var currentPage by remember { mutableStateOf(0) } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.currentPage }.collect { page -> + currentPage = page } - composable(Screen.Settings.route) { - SettingsScreen(viewModel = viewModel) + } + + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.safeDrawing) // 시스템 바 패딩 적용 + ) { + HorizontalPager( + state = pagerState, + modifier = Modifier.weight(1f) + ) { page -> + when (page) { + 0 -> { + DealListScreen( + viewModel = viewModel, + onNavigateToSettings = { + coroutineScope.launch { pagerState.animateScrollToPage(1) } + } + ) + } + 1 -> { + SettingsScreen(viewModel = viewModel) + } + } } } 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 6b19147..91bfbbc 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 @@ -1,22 +1,27 @@ package com.hotdeal.alarm.presentation.settings import android.Manifest +import android.app.DownloadManager +import android.content.Context import android.content.Intent import android.net.Uri import android.os.Build +import android.os.Environment import android.provider.Settings import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.* import androidx.compose.animation.core.* +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +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 @@ -26,14 +31,11 @@ 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.Brush 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.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hotdeal.alarm.domain.model.Keyword @@ -47,7 +49,7 @@ import com.hotdeal.alarm.util.PermissionHelper import com.hotdeal.alarm.util.VersionManager import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun SettingsScreen(viewModel: MainViewModel) { val context = LocalContext.current @@ -55,6 +57,10 @@ fun SettingsScreen(viewModel: MainViewModel) { val scope = rememberCoroutineScope() val currentPollingInterval by viewModel.pollingInterval.collectAsState() + // 탭 상태 + val pagerState = rememberPagerState(initialPage = 0, pageCount = { 3 }) + val tabTitles = listOf("알림", "사이트", "기타") + var showPermissionDialog by remember { mutableStateOf(false) } var permissionDialogTitle by remember { mutableStateOf("") } var permissionDialogMessage by remember { mutableStateOf("") } @@ -74,6 +80,74 @@ fun SettingsScreen(viewModel: MainViewModel) { val permissionStatus = PermissionHelper.checkAllPermissions(context) val hasEnabledSites = (uiState as? MainUiState.Success)?.siteConfigs?.any { it.isEnabled } ?: false + Column(modifier = Modifier.fillMaxSize()) { + // 탭 레이블 + PrimaryTabRow( + selectedTabIndex = pagerState.currentPage, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary + ) { + tabTitles.forEachIndexed { index, title -> + Tab( + selected = pagerState.currentPage == index, + onClick = { scope.launch { pagerState.animateScrollToPage(index) } }, + text = { + Text( + text = title, + fontWeight = if (pagerState.currentPage == index) FontWeight.Bold else FontWeight.Normal + ) + }, + selectedContentColor = MaterialTheme.colorScheme.primary, + unselectedContentColor = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + + // 탭 내용 (스와이프 가능) + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + beyondBoundsPageCount = 1 + ) { page -> + when (page) { + 0 -> NotificationTab( + viewModel = viewModel, + permissionStatus = permissionStatus, + hasEnabledSites = hasEnabledSites, + currentPollingInterval = currentPollingInterval, + onRequestPermission = { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) + } + } + ) + 1 -> SitesTab(viewModel = viewModel) + 2 -> MoreTab(viewModel = viewModel) + } + } + } + + if (showPermissionDialog) { + PermissionDialog( + title = permissionDialogTitle, + message = permissionDialogMessage, + onDismiss = { showPermissionDialog = false }, + onOpenSettings = { showPermissionDialog = false; permissionDialogAction() } + ) + } +} + +@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() + LazyColumn( modifier = Modifier .fillMaxSize() @@ -81,22 +155,12 @@ fun SettingsScreen(viewModel: MainViewModel) { verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(vertical = 16.dp) ) { - // 상단 알림 설정 섹션 + // 알림 권한 설정 item { NotificationSettingsHeader( permissionStatus = permissionStatus, hasEnabledSites = hasEnabledSites, - onRequestPermission = { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) - } - }, - onOpenSystemSettings = { - val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply { - putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName) - } - context.startActivity(intent) - } + onRequestPermission = onRequestPermission ) } @@ -112,7 +176,48 @@ fun SettingsScreen(viewModel: MainViewModel) { ) } - // 사이트 설정 섹션 + // 키워드 설정 + item { + SectionHeader( + title = "키워드 알림", + icon = Icons.Outlined.NotificationsActive, + description = "키워드를 등록하면 해당 키워드가 포함된 핫딜 알림" + ) + } + + item { + KeywordInputCard(onAdd = { keyword -> viewModel.addKeyword(keyword) }) + } + + if (uiState is MainUiState.Success) { + items((uiState as MainUiState.Success).keywords, key = { it.id }) { keyword -> + EnhancedKeywordCard( + keyword = keyword, + onToggle = { viewModel.toggleKeyword(keyword.id, !keyword.isEnabled) }, + onDelete = { viewModel.deleteKeyword(keyword.id) } + ) + } + } + + // 하단 여백 + item { + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +// ==================== 사이트 탭 ==================== +@Composable +private fun SitesTab(viewModel: MainViewModel) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { item { SectionHeader( title = "사이트 선택", @@ -132,51 +237,6 @@ fun SettingsScreen(viewModel: MainViewModel) { ) } } - - // 키워드 설정 섹션 - item { - Spacer(modifier = Modifier.height(8.dp)) - SectionHeader( - title = "키워드 알림", - icon = Icons.Outlined.NotificationsActive, - description = "키워드를 등록하면 해당 키워드가 포함된 핫딜 알림" - ) - } - - item { - KeywordInputCard( - onAdd = { keyword -> - viewModel.addKeyword(keyword) - } - ) - } - - items(state.keywords, key = { it.id }) { keyword -> - EnhancedKeywordCard( - keyword = keyword, - onToggle = { viewModel.toggleKeyword(keyword.id, !keyword.isEnabled) }, - onDelete = { viewModel.deleteKeyword(keyword.id) } - ) - } - - // 버전 정보 - item { - Spacer(modifier = Modifier.height(8.dp)) - VersionCard( - currentVersion = VersionManager.getCurrentVersion(context), - onCheckUpdate = { - scope.launch { - val currentCode = VersionManager.getCurrentVersionCode(context) - val remoteInfo = VersionManager.checkForUpdate() - 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() - } - } - } - ) - } } else -> { item { @@ -191,24 +251,252 @@ fun SettingsScreen(viewModel: MainViewModel) { } } } + + // 하단 여백 + item { + Spacer(modifier = Modifier.height(32.dp)) + } + } +} + +// ==================== 기타 탭 ==================== +@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) + + LaunchedEffect(toastEvent) { + toastEvent?.let { message -> + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() + } } - if (showPermissionDialog) { - PermissionDialog( - title = permissionDialogTitle, - message = permissionDialogMessage, - onDismiss = { showPermissionDialog = false }, - onOpenSettings = { showPermissionDialog = false; permissionDialogAction() } + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(vertical = 16.dp) + ) { + // 데이터 관리 + item { + SectionHeader( + title = "데이터 관리", + icon = Icons.Outlined.Storage, + description = "저장된 데이터를 관리합니다" + ) + } + + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(44.dp) + .background( + MaterialTheme.colorScheme.errorContainer, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.DeleteSweep, + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "파싱 데이터 삭제", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "저장된 모든 핫딜 데이터를 삭제합니다", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + FilledTonalIconButton( + onClick = { showDeleteDialog = true }, + colors = IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Icon( + imageVector = Icons.Outlined.Delete, + contentDescription = "삭제", + tint = MaterialTheme.colorScheme.error + ) + } + } + } + } + + // 앱 정보 + item { + Spacer(modifier = Modifier.height(8.dp)) + SectionHeader( + title = "앱 정보", + icon = Icons.Outlined.Info, + description = "앱 버전 및 업데이트" + ) + } + + item { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface + ), + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Box( + modifier = Modifier + .size(44.dp) + .background( + MaterialTheme.colorScheme.tertiaryContainer, + CircleShape + ), + contentAlignment = Alignment.Center + ) { + Icon( + imageVector = Icons.Filled.Info, + contentDescription = null, + tint = MaterialTheme.colorScheme.onTertiaryContainer, + modifier = Modifier.size(24.dp) + ) + } + Spacer(modifier = Modifier.width(12.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = "앱 버전", + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = "v${VersionManager.getCurrentVersion(context)}", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold + ) + } + 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)) { + downloadAndInstallApk(context, remoteInfo) + } else { + Toast.makeText(context, "최신 버전입니다", Toast.LENGTH_SHORT).show() + } + } + }, + enabled = !isCheckingUpdate, + shape = RoundedCornerShape(12.dp) + ) { + if (isCheckingUpdate) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp + ) + } else { + Icon( + imageVector = Icons.Outlined.SystemUpdate, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + } + Spacer(modifier = Modifier.width(6.dp)) + Text(if (isCheckingUpdate) "확인 중..." else "업데이트 확인") + } + } + } + } + + // 하단 여백 + item { + Spacer(modifier = Modifier.height(32.dp)) + } + } + + // 삭제 확인 다이얼로그 + if (showDeleteDialog) { + AlertDialog( + onDismissRequest = { showDeleteDialog = false }, + title = { Text("데이터 삭제") }, + text = { Text("저장된 모든 핫딜 데이터를 삭제하시겠습니까?\n이 작업은 되돌릴 수 없습니다.") }, + confirmButton = { + Button( + onClick = { + viewModel.deleteAllParsedData() + showDeleteDialog = false + }, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.error + ) + ) { + Text("삭제") + } + }, + dismissButton = { + TextButton(onClick = { showDeleteDialog = false }) { + Text("취소") + } + } ) } } +private fun downloadAndInstallApk(context: Context, updateInfo: com.hotdeal.alarm.util.UpdateInfo) { + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val request = DownloadManager.Request(Uri.parse(updateInfo.updateUrl)) + .setTitle("핫딜 알람 업데이트") + .setDescription("버전 ${updateInfo.version} 다운로드 중...") + .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) + .setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, "hotdeal_alarm_${updateInfo.version}.apk") + .setAllowedOverMetered(true) + .setAllowedOverRoaming(true) + + downloadManager.enqueue(request) + Toast.makeText(context, "다운로드가 시작되었습니다", Toast.LENGTH_SHORT).show() +} + +// ==================== 공통 컴포넌트 ==================== + @Composable private fun NotificationSettingsHeader( permissionStatus: PermissionHelper.PermissionStatus, hasEnabledSites: Boolean, - onRequestPermission: () -> Unit, - onOpenSystemSettings: () -> Unit + onRequestPermission: () -> Unit ) { val context = LocalContext.current @@ -220,10 +508,7 @@ private fun NotificationSettingsHeader( ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - // 헤더 + Column(modifier = Modifier.padding(16.dp)) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() @@ -261,7 +546,6 @@ private fun NotificationSettingsHeader( Spacer(modifier = Modifier.height(16.dp)) - // 알림 권한 상태 PermissionStatusRow( icon = if (permissionStatus.hasNotificationPermission) Icons.Filled.CheckCircle else Icons.Filled.Warning, title = "알림 권한", @@ -275,7 +559,6 @@ private fun NotificationSettingsHeader( Spacer(modifier = Modifier.height(8.dp)) - // 정확한 알람 (리마인더) 권한 상태 PermissionStatusRow( icon = if (permissionStatus.hasExactAlarmPermission) Icons.Filled.CheckCircle else Icons.Filled.Warning, title = "리마인더 및 알람", @@ -289,7 +572,6 @@ private fun NotificationSettingsHeader( Spacer(modifier = Modifier.height(8.dp)) - // 알 수 없는 앱 설치 권한 상태 PermissionStatusRow( icon = if (permissionStatus.canInstallUnknownApps) Icons.Filled.CheckCircle else Icons.Filled.Warning, title = "앱 설치 권한", @@ -303,7 +585,6 @@ private fun NotificationSettingsHeader( Spacer(modifier = Modifier.height(8.dp)) - // 사이트 선택 상태 PermissionStatusRow( icon = if (hasEnabledSites) Icons.Filled.CheckCircle else Icons.Filled.Error, title = "사이트 선택", @@ -311,7 +592,6 @@ private fun NotificationSettingsHeader( isOk = hasEnabledSites ) - // 시스템 설정 버튼 if (permissionStatus.hasNotificationPermission || permissionStatus.hasExactAlarmPermission || permissionStatus.canInstallUnknownApps) { Spacer(modifier = Modifier.height(12.dp)) OutlinedButton( @@ -371,6 +651,7 @@ private fun PermissionStatusRow( } } } + @Composable private fun SectionHeader( title: String, @@ -420,12 +701,8 @@ private fun PollingIntervalCard( ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { - Column( - modifier = Modifier.padding(20.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { + Column(modifier = Modifier.padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { Box( modifier = Modifier .size(44.dp) @@ -459,7 +736,6 @@ private fun PollingIntervalCard( Spacer(modifier = Modifier.height(16.dp)) - // 선택 옵션들 val options = listOf( Triple(1, "1분", "빠름"), Triple(2, "2분", "권장"), @@ -524,10 +800,6 @@ private fun PollingOptionChip( MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), - border = if (isSelected) - null - else - null, interactionSource = interactionSource ) { Column( @@ -575,15 +847,11 @@ private fun EnhancedSiteCard( ), elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - // 사이트 헤더 + Column(modifier = Modifier.padding(16.dp)) { Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { - // 사이트 색상 인디케이터 Box( modifier = Modifier .size(40.dp) @@ -612,7 +880,6 @@ private fun EnhancedSiteCard( ) } - // 진행률 표시 if (totalCount > 0) { Text( text = "${(enabledCount * 100 / totalCount)}%", @@ -623,7 +890,6 @@ private fun EnhancedSiteCard( } } - // 게시판 목록 if (configs.isNotEmpty()) { Spacer(modifier = Modifier.height(12.dp)) configs.forEach { config -> @@ -654,11 +920,8 @@ private fun EnhancedSiteCard( } @Composable -private fun KeywordInputCard( - onAdd: (String) -> Unit -) { +private fun KeywordInputCard(onAdd: (String) -> Unit) { var keywordText by remember { mutableStateOf("") } - var isExpanded by remember { mutableStateOf(false) } Card( modifier = Modifier.fillMaxWidth(), @@ -667,9 +930,7 @@ private fun KeywordInputCard( containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) ) ) { - Column( - modifier = Modifier.padding(16.dp) - ) { + Column(modifier = Modifier.padding(16.dp)) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically @@ -713,7 +974,6 @@ private fun KeywordInputCard( } } - // 힌트 AnimatedVisibility( visible = keywordText.isEmpty(), enter = fadeIn() + expandVertically(), @@ -742,9 +1002,9 @@ private fun EnhancedKeywordCard( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), colors = CardDefaults.elevatedCardColors( - containerColor = if (isEnabled) - Color(0xFFFFEBEE) // 옅은 빨간색 배경 (Material Red 50) - else + containerColor = if (isEnabled) + Color(0xFFFFEBEE) + else MaterialTheme.colorScheme.surface ), elevation = CardDefaults.elevatedCardElevation( @@ -757,12 +1017,11 @@ private fun EnhancedKeywordCard( .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { - // 키워드 아이콘 Surface( shape = RoundedCornerShape(10.dp), - color = if (isEnabled) - Color(0xFFE53935).copy(alpha = 0.12f) // 빨간색 - else + color = if (isEnabled) + Color(0xFFE53935).copy(alpha = 0.12f) + else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), modifier = Modifier.height(36.dp) ) { @@ -790,7 +1049,6 @@ private fun EnhancedKeywordCard( Spacer(modifier = Modifier.width(12.dp)) - // 키워드 텍스트 Column(modifier = Modifier.weight(1f)) { Text( text = keyword.keyword, @@ -801,18 +1059,14 @@ private fun EnhancedKeywordCard( Text( text = if (isEnabled) "알림 활성화" else "알림 비활성화", style = MaterialTheme.typography.bodySmall, - color = if (isEnabled) - Color(0xFFE53935) // 빨간색 - else + color = if (isEnabled) + Color(0xFFE53935) + else MaterialTheme.colorScheme.onSurfaceVariant ) } - // 액션 버튼들 - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp) - ) { - // 토글 버튼 + Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) { IconButton( onClick = onToggle, modifier = Modifier.size(36.dp) @@ -820,15 +1074,14 @@ private fun EnhancedKeywordCard( Icon( imageVector = if (isEnabled) Icons.Filled.Notifications else Icons.Outlined.NotificationsNone, contentDescription = if (isEnabled) "알림 끄기" else "알림 켜기", - tint = if (isEnabled) - Color(0xFFE53935) // 빨간색 - else + tint = if (isEnabled) + Color(0xFFE53935) + else MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(18.dp) ) } - // 삭제 버튼 IconButton( onClick = onDelete, modifier = Modifier.size(36.dp) @@ -844,70 +1097,3 @@ private fun EnhancedKeywordCard( } } } - -@Composable -private fun VersionCard( - currentVersion: String, - onCheckUpdate: () -> Unit -) { - Card( - modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface - ), - elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box( - modifier = Modifier - .size(44.dp) - .background( - MaterialTheme.colorScheme.tertiaryContainer, - CircleShape - ), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Filled.Info, - contentDescription = null, - tint = MaterialTheme.colorScheme.onTertiaryContainer, - modifier = Modifier.size(24.dp) - ) - } - - Spacer(modifier = Modifier.width(12.dp)) - - Column(modifier = Modifier.weight(1f)) { - Text( - text = "앱 버전", - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "v$currentVersion", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold - ) - } - - OutlinedButton( - onClick = onCheckUpdate, - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Outlined.SystemUpdate, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text("업데이트 확인") - } - } - } -}