diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 08f4a12..4130517 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -62,19 +62,30 @@ android:exported="false" android:foregroundServiceType="dataSync" /> - + - - - - - - - - + + + + + + + + + + + + + diff --git a/app/src/main/java/com/hotdeal/alarm/presentation/components/DealItem.kt b/app/src/main/java/com/hotdeal/alarm/presentation/components/DealItem.kt index f418f06..6b22fd9 100644 --- a/app/src/main/java/com/hotdeal/alarm/presentation/components/DealItem.kt +++ b/app/src/main/java/com/hotdeal/alarm/presentation/components/DealItem.kt @@ -160,26 +160,26 @@ fun DealItem( // 키워드 매칭 배지 if (deal.isKeywordMatch) { Surface( - shape = RoundedCornerShape(10.dp), + shape = RoundedCornerShape(8.dp), color = MaterialTheme.colorScheme.primary, - modifier = Modifier.height(28.dp) + modifier = Modifier.height(24.dp) ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(4.dp), - modifier = Modifier.padding(horizontal = 10.dp) + modifier = Modifier.padding(horizontal = 8.dp) ) { Icon( imageVector = Icons.Filled.Star, contentDescription = null, - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(14.dp) + tint = Color.White, + modifier = Modifier.size(12.dp) ) Text( text = "내 키워드", - style = MaterialTheme.typography.labelMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimary + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Medium, + color = Color.White ) } } 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 6d91405..7df48a9 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 @@ -1,32 +1,19 @@ package com.hotdeal.alarm.presentation.main -import android.content.Intent -import android.net.Uri import android.os.Bundle import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.height -import androidx.compose.ui.unit.dp -import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.lifecycleScope import com.hotdeal.alarm.ui.theme.HotDealTheme +import com.hotdeal.alarm.util.ApkDownloadManager import com.hotdeal.alarm.util.UpdateInfo import com.hotdeal.alarm.util.VersionManager import com.hotdeal.alarm.worker.WorkerScheduler @@ -36,17 +23,17 @@ import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { - + @Inject lateinit var workerScheduler: WorkerScheduler - + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() - - // 2분 간격으로 폴� 시작 + + // 2분 간격으로 폴링 시작 workerScheduler.schedulePeriodicPolling(2) - + setContent { HotDealTheme { Surface( @@ -54,28 +41,60 @@ class MainActivity : ComponentActivity() { color = MaterialTheme.colorScheme.background ) { val viewModel: MainViewModel = hiltViewModel() - + // 업데이트 체크 var updateInfo by remember { mutableStateOf(null) } var showUpdateDialog by remember { mutableStateOf(false) } - + var downloadProgress by remember { mutableStateOf(0) } + var isDownloading by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { checkForUpdate { info -> updateInfo = info showUpdateDialog = true } } - + MainScreen(viewModel = viewModel) - + // 업데이트 다이얼로그 if (showUpdateDialog && updateInfo != null) { UpdateDialog( updateInfo = updateInfo!!, - onDismiss = { showUpdateDialog = false }, + isDownloading = isDownloading, + downloadProgress = downloadProgress, + onDismiss = { + if (!isDownloading) showUpdateDialog = false + }, onUpdate = { - openUpdateUrl(updateInfo!!.updateUrl) - showUpdateDialog = false + // APK 다이렉트 다운로드 + isDownloading = true + val downloadId = ApkDownloadManager.downloadApk( + this@MainActivity, + updateInfo!! + ) + + // 다운로드 진행률 모니터링 + lifecycleScope.launch { + while (isDownloading) { + downloadProgress = ApkDownloadManager.getDownloadProgress( + this@MainActivity, + downloadId + ) + + if (ApkDownloadManager.isDownloadComplete( + this@MainActivity, + downloadId + ) + ) { + isDownloading = false + showUpdateDialog = false + ApkDownloadManager.installApk(this@MainActivity) + } + + kotlinx.coroutines.delay(500) + } + } } ) } @@ -83,7 +102,7 @@ class MainActivity : ComponentActivity() { } } } - + /** * 업데이트 체크 */ @@ -92,7 +111,7 @@ class MainActivity : ComponentActivity() { try { val currentVersionCode = VersionManager.getCurrentVersionCode(this@MainActivity) val remoteInfo = VersionManager.checkForUpdate() - + if (remoteInfo != null && VersionManager.isUpdateAvailable(currentVersionCode, remoteInfo.versionCode)) { // 토스트로 알림 Toast.makeText( @@ -100,7 +119,7 @@ class MainActivity : ComponentActivity() { "새로운 버전 ${remoteInfo.version}이(가) 있습니다", Toast.LENGTH_LONG ).show() - + // 다이얼로그 표시 onUpdateAvailable(remoteInfo) } @@ -109,18 +128,6 @@ class MainActivity : ComponentActivity() { } } } - - /** - * 업데이트 URL 열기 - */ - private fun openUpdateUrl(url: String) { - try { - val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url)) - startActivity(intent) - } catch (e: Exception) { - Toast.makeText(this, "브라우저를 열 수 없습니다", Toast.LENGTH_SHORT).show() - } - } } /** @@ -129,6 +136,8 @@ class MainActivity : ComponentActivity() { @Composable fun UpdateDialog( updateInfo: UpdateInfo, + isDownloading: Boolean, + downloadProgress: Int, onDismiss: () -> Unit, onUpdate: () -> Unit ) { @@ -136,9 +145,9 @@ fun UpdateDialog( onDismissRequest = onDismiss, title = { Text("업데이트 가능") }, text = { - androidx.compose.foundation.layout.Column { + Column { Text("새로운 버전 ${updateInfo.version}이(가) 출시되었습니다.") - androidx.compose.foundation.layout.Spacer(modifier = androidx.compose.ui.Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) Text( "변경사항:", style = MaterialTheme.typography.labelMedium @@ -146,16 +155,34 @@ fun UpdateDialog( updateInfo.changelog.take(3).forEach { change -> Text("• $change", style = MaterialTheme.typography.bodySmall) } + + if (isDownloading) { + Spacer(modifier = Modifier.height(16.dp)) + LinearProgressIndicator( + progress = downloadProgress / 100f, + modifier = Modifier.fillMaxWidth() + ) + Text( + "다운로드 중... $downloadProgress%", + style = MaterialTheme.typography.bodySmall, + modifier = Modifier.padding(top = 4.dp) + ) + } } }, confirmButton = { - Button(onClick = onUpdate) { - Text("업데이트") + Button( + onClick = onUpdate, + enabled = !isDownloading + ) { + Text(if (isDownloading) "다운로드 중..." else "업데이트") } }, dismissButton = { - TextButton(onClick = onDismiss) { - Text("나중에") + if (!isDownloading) { + TextButton(onClick = onDismiss) { + Text("나중에") + } } } ) 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 564d571..00c7e22 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 @@ -212,14 +212,14 @@ private fun NotificationSettingsHeader( ) { Card( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), + shape = RoundedCornerShape(16.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column( - modifier = Modifier.padding(20.dp) + modifier = Modifier.padding(16.dp) ) { // 헤더 Row( @@ -228,7 +228,7 @@ private fun NotificationSettingsHeader( ) { Box( modifier = Modifier - .size(44.dp) + .size(40.dp) .background( MaterialTheme.colorScheme.primaryContainer, CircleShape @@ -239,14 +239,14 @@ private fun NotificationSettingsHeader( imageVector = Icons.Filled.Notifications, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(22.dp) ) } Spacer(modifier = Modifier.width(12.dp)) Column { Text( text = "알림 설정", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold ) Text( @@ -259,19 +259,15 @@ private fun NotificationSettingsHeader( Spacer(modifier = Modifier.height(16.dp)) - // 알림 권한 상태 카드 - NotificationStatusCard( + // 알림 권한 상태 + NotificationStatusRow( icon = if (permissionStatus.hasNotificationPermission) Icons.Filled.CheckCircle else Icons.Filled.Warning, title = if (permissionStatus.hasNotificationPermission) "알림 권한 허용됨" else "알림 권한 필요", - description = if (permissionStatus.hasNotificationPermission) - "키워드 매칭 시 즉시 알림을 받을 수 있습니다" - else - "알림을 받으려면 권한을 허용해주세요", isOk = permissionStatus.hasNotificationPermission, action = if (!permissionStatus.hasNotificationPermission) { { onRequestPermission() } } else null, - actionLabel = "권한 허용하기", + actionLabel = "권한 허용", secondaryAction = if (permissionStatus.hasNotificationPermission) { { onOpenSystemSettings() } } else null, @@ -281,103 +277,49 @@ private fun NotificationSettingsHeader( Spacer(modifier = Modifier.height(12.dp)) // 사이트 선택 상태 - NotificationStatusCard( + NotificationStatusRow( icon = if (hasEnabledSites) Icons.Filled.CheckCircle else Icons.Filled.Error, title = if (hasEnabledSites) "사이트 선택 완료" else "사이트 선택 필요", - description = if (hasEnabledSites) - "모니터링할 사이트가 선택되었습니다" - else - "최소 1개 이상의 사이트를 선택해주세요", isOk = hasEnabledSites ) } } } - @Composable -private fun NotificationStatusCard( +private fun NotificationStatusRow( icon: ImageVector, title: String, - description: String, isOk: Boolean, action: (() -> Unit)? = null, actionLabel: String = "", secondaryAction: (() -> Unit)? = null, secondaryActionLabel: String = "" ) { - Surface( + Row( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(16.dp), - color = if (isOk) - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) - else - MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + verticalAlignment = Alignment.CenterVertically ) { - Column( - modifier = Modifier.padding(16.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = if (isOk) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, - modifier = Modifier.size(28.dp) - ) - Spacer(modifier = Modifier.width(12.dp)) - Column(modifier = Modifier.weight(1f)) { - Text( - text = title, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.SemiBold - ) - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = description, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + Icon( + imageVector = icon, + contentDescription = null, + tint = if (isOk) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + modifier = Modifier.weight(1f) + ) + if (action != null) { + TextButton(onClick = action) { + Text(actionLabel, style = MaterialTheme.typography.labelMedium) } - - if (action != null || secondaryAction != null) { - Spacer(modifier = Modifier.height(12.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - if (action != null) { - Button( - onClick = action, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Notifications, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text(actionLabel) - } - } - if (secondaryAction != null) { - OutlinedButton( - onClick = secondaryAction, - modifier = Modifier.weight(1f), - shape = RoundedCornerShape(12.dp) - ) { - Icon( - imageVector = Icons.Filled.Settings, - contentDescription = null, - modifier = Modifier.size(18.dp) - ) - Spacer(modifier = Modifier.width(6.dp)) - Text(secondaryActionLabel) - } - } - } + } + if (secondaryAction != null) { + TextButton(onClick = secondaryAction) { + Text(secondaryActionLabel, style = MaterialTheme.typography.labelMedium) } } } @@ -748,26 +690,13 @@ private fun EnhancedKeywordCard( onToggle: () -> Unit, onDelete: () -> Unit ) { - val scale by animateFloatAsState( - targetValue = if (keyword.isEnabled) 1f else 0.98f, - animationSpec = spring(stiffness = Spring.StiffnessLow), - label = "keyword_scale" - ) - Card( - modifier = Modifier - .fillMaxWidth() - .scale(scale), - shape = RoundedCornerShape(16.dp), + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( - containerColor = if (keyword.isEnabled) - MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f) - else - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) + containerColor = MaterialTheme.colorScheme.surface ), - elevation = CardDefaults.cardElevation( - defaultElevation = if (keyword.isEnabled) 2.dp else 0.dp - ) + elevation = CardDefaults.cardElevation(defaultElevation = 1.dp) ) { Row( modifier = Modifier @@ -777,25 +706,21 @@ private fun EnhancedKeywordCard( ) { // 키워드 아이콘 Box( - modifier = Modifier - .size(44.dp) + modifier = Modifier.size(40.dp) .background( - if (keyword.isEnabled) + if (keyword.isEnabled) MaterialTheme.colorScheme.primary - else - MaterialTheme.colorScheme.outline.copy(alpha = 0.3f), + else + MaterialTheme.colorScheme.outline.copy(alpha = 0.5f), CircleShape ), contentAlignment = Alignment.Center ) { Icon( - imageVector = if (keyword.isEnabled) Icons.Filled.Tag else Icons.Outlined.Tag, + imageVector = Icons.Filled.Tag, contentDescription = null, - tint = if (keyword.isEnabled) - MaterialTheme.colorScheme.onPrimary - else - MaterialTheme.colorScheme.outline, - modifier = Modifier.size(22.dp) + tint = Color.White, + modifier = Modifier.size(20.dp) ) } @@ -806,18 +731,15 @@ private fun EnhancedKeywordCard( Text( text = keyword.keyword, style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.SemiBold, - color = if (keyword.isEnabled) - MaterialTheme.colorScheme.onSurface - else - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.5f) + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface ) Text( - text = if (keyword.isEnabled) "알림 활성화됨" else "알림 비활성화", + text = if (keyword.isEnabled) "알림 활성화" else "알림 비활성화", style = MaterialTheme.typography.bodySmall, - color = if (keyword.isEnabled) - MaterialTheme.colorScheme.primary - else + color = if (keyword.isEnabled) + MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant ) } @@ -832,7 +754,7 @@ private fun EnhancedKeywordCard( ) ) - Spacer(modifier = Modifier.width(8.dp)) + Spacer(modifier = Modifier.width(4.dp)) // 삭제 버튼 IconButton( diff --git a/app/src/main/java/com/hotdeal/alarm/util/ApkDownloadManager.kt b/app/src/main/java/com/hotdeal/alarm/util/ApkDownloadManager.kt new file mode 100644 index 0000000..18bc043 --- /dev/null +++ b/app/src/main/java/com/hotdeal/alarm/util/ApkDownloadManager.kt @@ -0,0 +1,124 @@ +package com.hotdeal.alarm.util + +import android.app.DownloadManager +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Environment +import android.widget.Toast +import androidx.core.content.FileProvider +import java.io.File + +/** + * APK 다운로드 및 설치 관리자 + */ +object ApkDownloadManager { + + private const val APK_FILE_NAME = "hotdeal-alarm-update.apk" + + /** + * APK 다운로드 시작 + */ + 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) + if (outputFile.exists()) { + outputFile.delete() + } + + val request = DownloadManager.Request(Uri.parse(updateInfo.updateUrl)).apply { + setTitle("핫딜 알람 업데이트") + setDescription("버전 ${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") + } + + val downloadId = downloadManager.enqueue(request) + + Toast.makeText( + context, + "업데이트 다운로드 시작...", + Toast.LENGTH_SHORT + ).show() + + return downloadId + } + + /** + * APK 파일 설치 + */ + fun installApk(context: Context) { + val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), APK_FILE_NAME) + + if (!apkFile.exists()) { + Toast.makeText(context, "APK 파일을 찾을 수 없습니다", Toast.LENGTH_SHORT).show() + return + } + + try { + val apkUri = FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + apkFile + ) + + 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) + } + + context.startActivity(intent) + } catch (e: Exception) { + Toast.makeText(context, "설치를 시작할 수 없습니다: ${e.message}", Toast.LENGTH_SHORT).show() + } + } + + /** + * 다운로드 완료 여부 확인 + */ + fun isDownloadComplete(context: Context, downloadId: Long): Boolean { + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val query = DownloadManager.Query().setFilterById(downloadId) + + val cursor = downloadManager.query(query) + if (cursor.moveToFirst()) { + val statusIndex = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) + val status = cursor.getInt(statusIndex) + cursor.close() + return status == DownloadManager.STATUS_SUCCESSFUL + } + cursor.close() + return false + } + + /** + * 다운로드 진행률 가져오기 + */ + fun getDownloadProgress(context: Context, downloadId: Long): Int { + val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val query = DownloadManager.Query().setFilterById(downloadId) + + val cursor = downloadManager.query(query) + if (cursor.moveToFirst()) { + val bytesDownloadedIndex = cursor.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR) + val bytesTotalIndex = cursor.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES) + + val bytesDownloaded = cursor.getLong(bytesDownloadedIndex) + val bytesTotal = cursor.getLong(bytesTotalIndex) + + cursor.close() + + if (bytesTotal > 0) { + return ((bytesDownloaded * 100) / bytesTotal).toInt() + } + } + cursor.close() + return 0 + } +} diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..c842ff4 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/version.json b/version.json index b155494..e17492c 100644 --- a/version.json +++ b/version.json @@ -4,7 +4,7 @@ "minSdk": 31, "targetSdk": 35, "forceUpdate": false, - "updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases", + "updateUrl": "https://git.webpluss.net/attachments/02ab65b3-22f3-4f8e-b422-6c44588764b0", "changelog": [ "아이콘 디자인 개선 (알림 벨 + 불꽃)", "메인 화면 헤더 제거로 화면 넓게 사용",