fix: 키워드 카드 디자인 단순화 및 APK 다이렉트 다운로드 구현

- 키워드 카드 배경 단일화 (2중 배경 제거)
- '내 키워드' 배지 디자인 단순화
- 알림 설정 UI 간소화
- APK 다이렉트 다운로드 기능 추가
- FileProvider 설정 추가
- version.json updateUrl을 실제 APK 다운로드 링크로 변경
This commit is contained in:
sanjeok77
2026-03-04 09:04:33 +09:00
parent 324f68256c
commit f78647200c
7 changed files with 297 additions and 201 deletions

View File

@@ -62,19 +62,30 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Boot Receiver -->
<!-- Boot Receiver -->
<!-- Widget -->
<receiver
android:name=".widget.HotDealWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info" />
</receiver>
</application>
<!-- Widget -->
<receiver
android:name=".widget.HotDealWidget"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/widget_info" />
</receiver>
<!-- FileProvider for APK installation -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -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
)
}
}

View File

@@ -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분 간격으로 폴<EFBFBD> 시작
// 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<UpdateInfo?>(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("나중에")
}
}
}
)

View File

@@ -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(

View File

@@ -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
}
}

View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path
name="downloads"
path="Download" />
<cache-path
name="cache"
path="." />
<files-path
name="files"
path="." />
</paths>

View File

@@ -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": [
"아이콘 디자인 개선 (알림 벨 + 불꽃)",
"메인 화면 헤더 제거로 화면 넓게 사용",