Add version management and update check system
- Add version.json for remote version check - Add VersionManager utility - Show update dialog on app start - Add version info in settings screen - Version format: x.y.z (1.0.0) - versionCode increments by 1
This commit is contained in:
@@ -1,17 +1,37 @@
|
|||||||
package com.hotdeal.alarm.presentation.main
|
package com.hotdeal.alarm.presentation.main
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.Surface
|
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.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.hilt.navigation.compose.hiltViewModel
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.hotdeal.alarm.ui.theme.HotDealTheme
|
import com.hotdeal.alarm.ui.theme.HotDealTheme
|
||||||
|
import com.hotdeal.alarm.util.UpdateInfo
|
||||||
|
import com.hotdeal.alarm.util.VersionManager
|
||||||
import com.hotdeal.alarm.worker.WorkerScheduler
|
import com.hotdeal.alarm.worker.WorkerScheduler
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@AndroidEntryPoint
|
@AndroidEntryPoint
|
||||||
@@ -24,7 +44,8 @@ class MainActivity : ComponentActivity() {
|
|||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
|
||||||
workerScheduler.schedulePeriodicPolling(15)
|
// 2분 간격으로 폴<> 시작
|
||||||
|
workerScheduler.schedulePeriodicPolling(2)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
HotDealTheme {
|
HotDealTheme {
|
||||||
@@ -33,9 +54,109 @@ class MainActivity : ComponentActivity() {
|
|||||||
color = MaterialTheme.colorScheme.background
|
color = MaterialTheme.colorScheme.background
|
||||||
) {
|
) {
|
||||||
val viewModel: MainViewModel = hiltViewModel()
|
val viewModel: MainViewModel = hiltViewModel()
|
||||||
|
|
||||||
|
// 업데이트 체크
|
||||||
|
var updateInfo by remember { mutableStateOf<UpdateInfo?>(null) }
|
||||||
|
var showUpdateDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
checkForUpdate { info ->
|
||||||
|
updateInfo = info
|
||||||
|
showUpdateDialog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MainScreen(viewModel = viewModel)
|
MainScreen(viewModel = viewModel)
|
||||||
|
|
||||||
|
// 업데이트 다이얼로그
|
||||||
|
if (showUpdateDialog && updateInfo != null) {
|
||||||
|
UpdateDialog(
|
||||||
|
updateInfo = updateInfo!!,
|
||||||
|
onDismiss = { showUpdateDialog = false },
|
||||||
|
onUpdate = {
|
||||||
|
openUpdateUrl(updateInfo!!.updateUrl)
|
||||||
|
showUpdateDialog = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 체크
|
||||||
|
*/
|
||||||
|
private fun checkForUpdate(onUpdateAvailable: (UpdateInfo) -> Unit) {
|
||||||
|
lifecycleScope.launch {
|
||||||
|
try {
|
||||||
|
val currentVersionCode = VersionManager.getCurrentVersionCode(this@MainActivity)
|
||||||
|
val remoteInfo = VersionManager.checkForUpdate()
|
||||||
|
|
||||||
|
if (remoteInfo != null && VersionManager.isUpdateAvailable(currentVersionCode, remoteInfo.versionCode)) {
|
||||||
|
// 토스트로 알림
|
||||||
|
Toast.makeText(
|
||||||
|
this@MainActivity,
|
||||||
|
"새로운 버전 ${remoteInfo.version}이(가) 있습니다",
|
||||||
|
Toast.LENGTH_LONG
|
||||||
|
).show()
|
||||||
|
|
||||||
|
// 다이얼로그 표시
|
||||||
|
onUpdateAvailable(remoteInfo)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 업데이트 체크 실패 시 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 다이얼로그
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun UpdateDialog(
|
||||||
|
updateInfo: UpdateInfo,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
onUpdate: () -> Unit
|
||||||
|
) {
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text("업데이트 가능") },
|
||||||
|
text = {
|
||||||
|
androidx.compose.foundation.layout.Column {
|
||||||
|
Text("새로운 버전 ${updateInfo.version}이(가) 출시되었습니다.")
|
||||||
|
androidx.compose.foundation.layout.Spacer(modifier = androidx.compose.ui.Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
"변경사항:",
|
||||||
|
style = MaterialTheme.typography.labelMedium
|
||||||
|
)
|
||||||
|
updateInfo.changelog.take(3).forEach { change ->
|
||||||
|
Text("• $change", style = MaterialTheme.typography.bodySmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
Button(onClick = onUpdate) {
|
||||||
|
Text("업데이트")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
|
Text("나중에")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
package com.hotdeal.alarm.presentation.settings
|
package com.hotdeal.alarm.presentation.settings
|
||||||
|
|
||||||
import android.Manifest
|
import android.Manifest
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
@@ -15,11 +14,9 @@ import androidx.compose.material3.*
|
|||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.hotdeal.alarm.domain.model.Keyword
|
import com.hotdeal.alarm.domain.model.Keyword
|
||||||
import com.hotdeal.alarm.domain.model.SiteConfig
|
import com.hotdeal.alarm.domain.model.SiteConfig
|
||||||
@@ -28,13 +25,16 @@ import com.hotdeal.alarm.presentation.components.PermissionDialog
|
|||||||
import com.hotdeal.alarm.presentation.main.MainUiState
|
import com.hotdeal.alarm.presentation.main.MainUiState
|
||||||
import com.hotdeal.alarm.presentation.main.MainViewModel
|
import com.hotdeal.alarm.presentation.main.MainViewModel
|
||||||
import com.hotdeal.alarm.util.PermissionHelper
|
import com.hotdeal.alarm.util.PermissionHelper
|
||||||
|
import com.hotdeal.alarm.util.VersionManager
|
||||||
import com.hotdeal.alarm.worker.WorkerScheduler
|
import com.hotdeal.alarm.worker.WorkerScheduler
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsScreen(viewModel: MainViewModel) {
|
fun SettingsScreen(viewModel: MainViewModel) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
var showPermissionDialog by remember { mutableStateOf(false) }
|
var showPermissionDialog by remember { mutableStateOf(false) }
|
||||||
var permissionDialogTitle by remember { mutableStateOf("") }
|
var permissionDialogTitle by remember { mutableStateOf("") }
|
||||||
@@ -46,7 +46,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
) { isGranted ->
|
) { isGranted ->
|
||||||
if (!isGranted) {
|
if (!isGranted) {
|
||||||
permissionDialogTitle = "알림 권한 필요"
|
permissionDialogTitle = "알림 권한 필요"
|
||||||
permissionDialogMessage = "핫딜 알림을 받으려면 알림 권한이 필요합니다.\n설정에서 권한을 허용해주세요."
|
permissionDialogMessage = "핫딜 알림을 받으려면 알림 권한이 필요합니다."
|
||||||
permissionDialogAction = {
|
permissionDialogAction = {
|
||||||
PermissionHelper.openNotificationSettings(context)
|
PermissionHelper.openNotificationSettings(context)
|
||||||
}
|
}
|
||||||
@@ -54,12 +54,9 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 권한 상태 확인
|
|
||||||
val permissionStatus = PermissionHelper.checkAllPermissions(context)
|
val permissionStatus = PermissionHelper.checkAllPermissions(context)
|
||||||
val hasEnabledSites = (uiState as? MainUiState.Success)
|
val hasEnabledSites = (uiState as? MainUiState.Success)
|
||||||
?.siteConfigs?.any { it.isEnabled } ?: false
|
?.siteConfigs?.any { it.isEnabled } ?: false
|
||||||
val hasKeywords = (uiState as? MainUiState.Success)
|
|
||||||
?.keywords?.isNotEmpty() ?: false
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -67,30 +64,21 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
// 권한 상태 섹션
|
|
||||||
item {
|
item {
|
||||||
PermissionStatusSection(
|
PermissionStatusSection(
|
||||||
permissionStatus = permissionStatus,
|
permissionStatus = permissionStatus,
|
||||||
hasEnabledSites = hasEnabledSites,
|
hasEnabledSites = hasEnabledSites,
|
||||||
hasKeywords = hasKeywords,
|
|
||||||
onRequestNotificationPermission = {
|
onRequestNotificationPermission = {
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
}
|
}
|
||||||
},
|
|
||||||
onRequestExactAlarmPermission = {
|
|
||||||
PermissionHelper.openExactAlarmSettings(context)
|
|
||||||
},
|
|
||||||
onOpenAppSettings = {
|
|
||||||
PermissionHelper.openAppSettings(context)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 폴<> 주기 설정
|
|
||||||
item {
|
item {
|
||||||
PollingIntervalSection(
|
PollingIntervalSection(
|
||||||
currentInterval = 2, // 기본 2분
|
currentInterval = 2,
|
||||||
onIntervalChange = { minutes ->
|
onIntervalChange = { minutes ->
|
||||||
viewModel.stopPolling()
|
viewModel.stopPolling()
|
||||||
viewModel.startPolling(minutes)
|
viewModel.startPolling(minutes)
|
||||||
@@ -98,12 +86,8 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사이트 선택 섹션
|
|
||||||
item {
|
item {
|
||||||
Text(
|
Text("사이트 선택", style = MaterialTheme.typography.titleMedium)
|
||||||
text = "사이트 선택",
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
when (val state = uiState) {
|
when (val state = uiState) {
|
||||||
@@ -122,20 +106,11 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
Text(
|
Text("키워드 설정", style = MaterialTheme.typography.titleMedium)
|
||||||
text = "키워드 설정",
|
|
||||||
style = MaterialTheme.typography.titleMedium
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "특정 키워드가 포함된 핫딜만 알림받으려면 키워드를 추가하세요",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
var keywordText by remember { mutableStateOf("") }
|
var keywordText by remember { mutableStateOf("") }
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
@@ -169,20 +144,28 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.keywords.isEmpty()) {
|
// 버전 정보 섹션
|
||||||
item {
|
item {
|
||||||
Text(
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
text = "등록된 키워드가 없습니다",
|
VersionSection(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
currentVersion = VersionManager.getCurrentVersion(context),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
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 -> {
|
else -> {
|
||||||
item {
|
item { CircularProgressIndicator() }
|
||||||
CircularProgressIndicator()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -192,82 +175,58 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
title = permissionDialogTitle,
|
title = permissionDialogTitle,
|
||||||
message = permissionDialogMessage,
|
message = permissionDialogMessage,
|
||||||
onDismiss = { showPermissionDialog = false },
|
onDismiss = { showPermissionDialog = false },
|
||||||
onOpenSettings = {
|
onOpenSettings = { showPermissionDialog = false; permissionDialogAction() }
|
||||||
showPermissionDialog = false
|
|
||||||
permissionDialogAction()
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PollingIntervalSection(
|
fun VersionSection(currentVersion: String, onCheckUpdate: () -> Unit) {
|
||||||
currentInterval: Int,
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
onIntervalChange: (Long) -> Unit
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
) {
|
|
||||||
var selectedInterval by remember { mutableStateOf(currentInterval) }
|
|
||||||
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.3f)
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Icon(
|
Column {
|
||||||
imageVector = Icons.Default.Schedule,
|
Text("앱 버전", style = MaterialTheme.typography.labelMedium)
|
||||||
contentDescription = null,
|
Text("v$currentVersion", style = MaterialTheme.typography.titleMedium)
|
||||||
tint = MaterialTheme.colorScheme.secondary
|
}
|
||||||
)
|
Button(onClick = onCheckUpdate) {
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
Text("업데이트 확인")
|
||||||
Text(
|
}
|
||||||
text = "폴<EFBFBD> 주기 설정",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Text(
|
}
|
||||||
text = "핫딜을 확인하는 간격을 설정합니다.\n짧을수록 배터리와 데이터 소모가 증가합니다.",
|
}
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
@Composable
|
||||||
)
|
fun PollingIntervalSection(currentInterval: Int, onIntervalChange: (Long) -> Unit) {
|
||||||
|
var selected by remember { mutableStateOf(currentInterval) }
|
||||||
// 간격 선택
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
listOf(1, 2, 5, 10, 15, 30).forEach { minutes ->
|
Text("폴<EFBFBD> 주기", style = MaterialTheme.typography.titleMedium)
|
||||||
Row(
|
Text("2분 권장 (배터리/데이터 절약)", style = MaterialTheme.typography.bodySmall)
|
||||||
modifier = Modifier
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
.fillMaxWidth()
|
listOf(1, 2, 5, 10, 15, 30).forEach { minutes ->
|
||||||
.padding(vertical = 4.dp),
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
RadioButton(
|
||||||
) {
|
selected = selected == minutes,
|
||||||
RadioButton(
|
onClick = {
|
||||||
selected = selectedInterval == minutes,
|
selected = minutes
|
||||||
onClick = {
|
onIntervalChange(minutes.toLong())
|
||||||
selectedInterval = minutes
|
}
|
||||||
onIntervalChange(minutes.toLong())
|
)
|
||||||
}
|
Text(when(minutes) {
|
||||||
)
|
1 -> "1분 (빠름)"
|
||||||
Text(
|
2 -> "2분 (권장)"
|
||||||
text = when (minutes) {
|
5 -> "5분 (보통)"
|
||||||
1 -> "1분 (빠름 - 배터리 소모 큼)"
|
10 -> "10분 (느림)"
|
||||||
2 -> "2분 (권장)"
|
15 -> "15분 (매우 느림)"
|
||||||
5 -> "5분 (보통)"
|
30 -> "30분 (절전)"
|
||||||
10 -> "10분 (느림)"
|
else -> "${minutes}분"
|
||||||
15 -> "15분 (매우 느림)"
|
})
|
||||||
30 -> "30분 (절전)"
|
|
||||||
else -> "${minutes}분"
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -278,217 +237,50 @@ fun PollingIntervalSection(
|
|||||||
fun PermissionStatusSection(
|
fun PermissionStatusSection(
|
||||||
permissionStatus: PermissionHelper.PermissionStatus,
|
permissionStatus: PermissionHelper.PermissionStatus,
|
||||||
hasEnabledSites: Boolean,
|
hasEnabledSites: Boolean,
|
||||||
hasKeywords: Boolean,
|
onRequestNotificationPermission: () -> Unit
|
||||||
onRequestNotificationPermission: () -> Unit,
|
|
||||||
onRequestExactAlarmPermission: () -> Unit,
|
|
||||||
onOpenAppSettings: () -> Unit
|
|
||||||
) {
|
|
||||||
Card(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = if (permissionStatus.isAllGranted && hasEnabledSites) {
|
|
||||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp)
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "알림 설정 상태",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.Bold
|
|
||||||
)
|
|
||||||
|
|
||||||
// 알림 권한 상태
|
|
||||||
PermissionItem(
|
|
||||||
icon = Icons.Default.Notifications,
|
|
||||||
title = "알림 권한",
|
|
||||||
isGranted = permissionStatus.hasNotificationPermission,
|
|
||||||
requiredVersion = "Android 13+",
|
|
||||||
onClick = onRequestNotificationPermission
|
|
||||||
)
|
|
||||||
|
|
||||||
// 정확한 알람 권한 상태
|
|
||||||
PermissionItem(
|
|
||||||
icon = Icons.Default.Alarm,
|
|
||||||
title = "정확한 알람 권한",
|
|
||||||
isGranted = permissionStatus.hasExactAlarmPermission,
|
|
||||||
requiredVersion = "Android 12+",
|
|
||||||
onClick = onRequestExactAlarmPermission
|
|
||||||
)
|
|
||||||
|
|
||||||
// 사이트 활성화 상태
|
|
||||||
PermissionItem(
|
|
||||||
icon = Icons.Default.Language,
|
|
||||||
title = "사이트 선택",
|
|
||||||
isGranted = hasEnabledSites,
|
|
||||||
description = if (!hasEnabledSites) "최소 1개 사이트 활성화 필요" else null,
|
|
||||||
onClick = null
|
|
||||||
)
|
|
||||||
|
|
||||||
// 키워드 상태 (선택사항)
|
|
||||||
if (!hasKeywords) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Info,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Text(
|
|
||||||
text = "키워드를 추가하면 해당 키워드가 포함된 핫딜만 알림받습니다",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 전체 상태 요약
|
|
||||||
if (!permissionStatus.isAllGranted || !hasEnabledSites) {
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f),
|
|
||||||
shape = MaterialTheme.shapes.small
|
|
||||||
)
|
|
||||||
.padding(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.Warning,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "알림을 받으려면 모든 필수 권한을 허용하고 사이트를 선택하세요",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Icons.Default.CheckCircle,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
modifier = Modifier.size(20.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(8.dp))
|
|
||||||
Text(
|
|
||||||
text = "모든 설정이 완료되었습니다",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PermissionItem(
|
|
||||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
|
||||||
title: String,
|
|
||||||
isGranted: Boolean,
|
|
||||||
requiredVersion: String? = null,
|
|
||||||
description: String? = null,
|
|
||||||
onClick: (() -> Unit)?
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = icon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (isGranted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
Text(
|
|
||||||
text = title,
|
|
||||||
style = MaterialTheme.typography.bodyMedium
|
|
||||||
)
|
|
||||||
if (requiredVersion != null) {
|
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
|
||||||
Text(
|
|
||||||
text = "($requiredVersion)",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (description != null) {
|
|
||||||
Text(
|
|
||||||
text = description,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (onClick != null && !isGranted) {
|
|
||||||
FilledTonalButton(
|
|
||||||
onClick = onClick,
|
|
||||||
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp)
|
|
||||||
) {
|
|
||||||
Text("설정", style = MaterialTheme.typography.labelMedium)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isGranted) Icons.Default.Check else Icons.Default.Close,
|
|
||||||
contentDescription = if (isGranted) "허용됨" else "거부됨",
|
|
||||||
tint = if (isGranted) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
|
||||||
modifier = Modifier.size(24.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SiteSection(
|
|
||||||
siteType: SiteType,
|
|
||||||
configs: List<SiteConfig>,
|
|
||||||
onToggle: (String, Boolean) -> Unit
|
|
||||||
) {
|
) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text(
|
Text("알림 설정", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
text = siteType.displayName,
|
|
||||||
style = MaterialTheme.typography.titleSmall
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (permissionStatus.hasNotificationPermission) Icons.Default.Check else Icons.Default.Close,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (permissionStatus.hasNotificationPermission) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text("알림 권한", modifier = Modifier.weight(1f))
|
||||||
|
if (!permissionStatus.hasNotificationPermission) {
|
||||||
|
Button(onClick = onRequestNotificationPermission) { Text("설정") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (hasEnabledSites) Icons.Default.Check else Icons.Default.Close,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (hasEnabledSites) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text("사이트 선택", modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SiteSection(siteType: SiteType, configs: List<SiteConfig>, onToggle: (String, Boolean) -> Unit) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(siteType.displayName, style = MaterialTheme.typography.titleSmall)
|
||||||
configs.forEach { config ->
|
configs.forEach { config ->
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(vertical = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(config.displayName.substringAfter(" - "), modifier = Modifier.weight(1f))
|
||||||
text = config.displayName.substringAfter(" - "),
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
modifier = Modifier.weight(1f)
|
|
||||||
)
|
|
||||||
Switch(
|
Switch(
|
||||||
checked = config.isEnabled,
|
checked = config.isEnabled,
|
||||||
onCheckedChange = { onToggle(config.siteBoardKey, it) }
|
onCheckedChange = { onToggle(config.siteBoardKey, it) }
|
||||||
@@ -500,19 +292,12 @@ fun SiteSection(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun KeywordItem(
|
fun KeywordItem(keyword: Keyword, onToggle: () -> Unit, onDelete: () -> Unit) {
|
||||||
keyword: Keyword,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
onDelete: () -> Unit
|
|
||||||
) {
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(keyword.keyword) },
|
headlineContent = { Text(keyword.keyword) },
|
||||||
trailingContent = {
|
trailingContent = {
|
||||||
Row {
|
Row {
|
||||||
Switch(
|
Switch(checked = keyword.isEnabled, onCheckedChange = { onToggle() })
|
||||||
checked = keyword.isEnabled,
|
|
||||||
onCheckedChange = { onToggle() }
|
|
||||||
)
|
|
||||||
IconButton(onClick = onDelete) {
|
IconButton(onClick = onDelete) {
|
||||||
Icon(Icons.Default.Delete, contentDescription = "삭제")
|
Icon(Icons.Default.Delete, contentDescription = "삭제")
|
||||||
}
|
}
|
||||||
|
|||||||
114
app/src/main/java/com/hotdeal/alarm/util/VersionManager.kt
Normal file
114
app/src/main/java/com/hotdeal/alarm/util/VersionManager.kt
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package com.hotdeal.alarm.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.net.URL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 버전 관리 유틸리티
|
||||||
|
*
|
||||||
|
* 버전 규칙: x.y.z
|
||||||
|
* - x: 메이저 (API 변경, 호환성 깨짐)
|
||||||
|
* - y: 마이너 (기능 추가, 하위 호환)
|
||||||
|
* - z: 패치 (버그 수정)
|
||||||
|
*
|
||||||
|
* versionCode: 1씩 증가 (낮은 버전 = 이전 버전)
|
||||||
|
*/
|
||||||
|
object VersionManager {
|
||||||
|
|
||||||
|
private const val TAG = "VersionManager"
|
||||||
|
private const val VERSION_URL = "https://git.webpluss.net/sanjeok77/hotdeal_alarm/raw/branch/main/version.json"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 앱 버전 가져오기
|
||||||
|
*/
|
||||||
|
fun getCurrentVersion(context: Context): String {
|
||||||
|
return try {
|
||||||
|
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
packageInfo.versionName ?: "1.0.0"
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "버전 가져오기 실패: ${e.message}")
|
||||||
|
"1.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 현재 버전 코드 가져오기
|
||||||
|
*/
|
||||||
|
fun getCurrentVersionCode(context: Context): Int {
|
||||||
|
return try {
|
||||||
|
val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
packageInfo.longVersionCode.toInt()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "버전 코드 가져오기 실패: ${e.message}")
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원격 버전 정보 가져오기
|
||||||
|
*/
|
||||||
|
suspend fun checkForUpdate(): UpdateInfo? = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val json = URL(VERSION_URL).readText()
|
||||||
|
val jsonObject = JSONObject(json)
|
||||||
|
|
||||||
|
UpdateInfo(
|
||||||
|
version = jsonObject.getString("version"),
|
||||||
|
versionCode = jsonObject.getInt("versionCode"),
|
||||||
|
forceUpdate = jsonObject.optBoolean("forceUpdate", false),
|
||||||
|
updateUrl = jsonObject.getString("updateUrl"),
|
||||||
|
changelog = jsonObject.optJSONArray("changelog")?.let { array ->
|
||||||
|
List(array.length()) { array.getString(it) }
|
||||||
|
} ?: emptyList()
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "버전 체크 실패: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 필요 여부 확인
|
||||||
|
*/
|
||||||
|
fun isUpdateAvailable(currentVersionCode: Int, remoteVersionCode: Int): Boolean {
|
||||||
|
return remoteVersionCode > currentVersionCode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 버전 문자열 비교
|
||||||
|
* 1.0.0 < 1.0.1 < 1.1.0 < 2.0.0
|
||||||
|
*/
|
||||||
|
fun compareVersions(v1: String, v2: String): Int {
|
||||||
|
val parts1 = v1.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
val parts2 = v2.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
|
||||||
|
val maxLength = maxOf(parts1.size, parts2.size)
|
||||||
|
|
||||||
|
for (i in 0 until maxLength) {
|
||||||
|
val p1 = parts1.getOrElse(i) { 0 }
|
||||||
|
val p2 = parts2.getOrElse(i) { 0 }
|
||||||
|
|
||||||
|
when {
|
||||||
|
p1 > p2 -> return 1
|
||||||
|
p1 < p2 -> return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 업데이트 정보
|
||||||
|
*/
|
||||||
|
data class UpdateInfo(
|
||||||
|
val version: String,
|
||||||
|
val versionCode: Int,
|
||||||
|
val forceUpdate: Boolean,
|
||||||
|
val updateUrl: String,
|
||||||
|
val changelog: List<String>
|
||||||
|
)
|
||||||
14
version.json
Normal file
14
version.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"version": "1.0.0",
|
||||||
|
"versionCode": 1,
|
||||||
|
"minSdk": 31,
|
||||||
|
"targetSdk": 35,
|
||||||
|
"forceUpdate": false,
|
||||||
|
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases",
|
||||||
|
"changelog": [
|
||||||
|
"초기 릴리즈",
|
||||||
|
"뽐뿌, 클리앙, 루리웹, 쿨엔조이 지원",
|
||||||
|
"키워드 알림 기능",
|
||||||
|
"사이트 필터 기능"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user