Fix keyword alarm and add visual indicators
- Fix: Only keyword-matched deals send notifications - Add: Visual indicator for keyword-matched deals (badge + border) - Add: Polling interval setting with persistence - Add: Version check in settings - Add: Update dialog on app start
This commit is contained in:
@@ -0,0 +1,40 @@
|
|||||||
|
package com.hotdeal.alarm.data.local.preferences
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 앱 설정 저장 (DataStore)
|
||||||
|
*/
|
||||||
|
class AppSettings(private val context: Context) {
|
||||||
|
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "app_settings")
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val POLLING_INTERVAL_KEY = intPreferencesKey("polling_interval_minutes")
|
||||||
|
private const val DEFAULT_INTERVAL = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴<> 주기 (분)
|
||||||
|
*/
|
||||||
|
val pollingInterval: Flow<Int> = context.dataStore.data
|
||||||
|
.map { preferences ->
|
||||||
|
preferences[POLLING_INTERVAL_KEY] ?: DEFAULT_INTERVAL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴<> 주기 설정 저장
|
||||||
|
*/
|
||||||
|
suspend fun setPollingInterval(minutes: Int) {
|
||||||
|
context.dataStore.edit { preferences ->
|
||||||
|
preferences[POLLING_INTERVAL_KEY] = minutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.hotdeal.alarm.data.local.db.AppDatabase
|
|||||||
import com.hotdeal.alarm.data.local.db.dao.HotDealDao
|
import com.hotdeal.alarm.data.local.db.dao.HotDealDao
|
||||||
import com.hotdeal.alarm.data.local.db.dao.KeywordDao
|
import com.hotdeal.alarm.data.local.db.dao.KeywordDao
|
||||||
import com.hotdeal.alarm.data.local.db.dao.SiteConfigDao
|
import com.hotdeal.alarm.data.local.db.dao.SiteConfigDao
|
||||||
|
import com.hotdeal.alarm.data.local.preferences.AppSettings
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -52,4 +53,12 @@ object DatabaseModule {
|
|||||||
fun provideKeywordDao(database: AppDatabase): KeywordDao {
|
fun provideKeywordDao(database: AppDatabase): KeywordDao {
|
||||||
return database.keywordDao()
|
return database.keywordDao()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideAppSettings(
|
||||||
|
@ApplicationContext context: Context
|
||||||
|
): AppSettings {
|
||||||
|
return AppSettings(context)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.hotdeal.alarm.presentation.components
|
|||||||
|
|
||||||
import androidx.compose.animation.core.*
|
import androidx.compose.animation.core.*
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
@@ -9,14 +10,16 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Favorite
|
import androidx.compose.material.icons.filled.Favorite
|
||||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||||
|
import androidx.compose.material.icons.filled.NotificationsActive
|
||||||
import androidx.compose.material.icons.filled.Share
|
import androidx.compose.material.icons.filled.Share
|
||||||
import androidx.compose.material.icons.filled.Star
|
|
||||||
import androidx.compose.material3.*
|
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.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
|
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.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.hotdeal.alarm.domain.model.HotDeal
|
import com.hotdeal.alarm.domain.model.HotDeal
|
||||||
@@ -24,7 +27,6 @@ import com.hotdeal.alarm.ui.theme.Spacing
|
|||||||
import com.hotdeal.alarm.ui.theme.getSiteColor
|
import com.hotdeal.alarm.ui.theme.getSiteColor
|
||||||
import com.hotdeal.alarm.util.ShareHelper
|
import com.hotdeal.alarm.util.ShareHelper
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun DealItem(
|
fun DealItem(
|
||||||
deal: HotDeal,
|
deal: HotDeal,
|
||||||
@@ -45,10 +47,34 @@ fun DealItem(
|
|||||||
|
|
||||||
val siteColor = getSiteColor(deal.siteType)
|
val siteColor = getSiteColor(deal.siteType)
|
||||||
|
|
||||||
|
// 키워드 매칭된 핫딜은 다른 색상으로 강조
|
||||||
|
val cardColor = if (deal.isKeywordMatch) {
|
||||||
|
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.colorScheme.surface
|
||||||
|
}
|
||||||
|
|
||||||
|
val borderColor = if (deal.isKeywordMatch) {
|
||||||
|
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||||
|
} else {
|
||||||
|
Color.Transparent
|
||||||
|
}
|
||||||
|
|
||||||
|
val cardModifier = if (deal.isKeywordMatch) {
|
||||||
|
modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.border(2.dp, borderColor, MaterialTheme.shapes.large)
|
||||||
|
} else {
|
||||||
|
modifier.fillMaxWidth()
|
||||||
|
}
|
||||||
|
|
||||||
ElevatedCard(
|
ElevatedCard(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = cardModifier,
|
||||||
shape = MaterialTheme.shapes.large,
|
shape = MaterialTheme.shapes.large,
|
||||||
elevation = CardDefaults.elevatedCardElevation(defaultElevation = 2.dp),
|
colors = CardDefaults.elevatedCardColors(containerColor = cardColor),
|
||||||
|
elevation = CardDefaults.elevatedCardElevation(
|
||||||
|
defaultElevation = if (deal.isKeywordMatch) 4.dp else 2.dp
|
||||||
|
),
|
||||||
onClick = onClick
|
onClick = onClick
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -88,24 +114,42 @@ fun DealItem(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.width(Spacing.sm))
|
Spacer(modifier = Modifier.width(Spacing.sm))
|
||||||
|
|
||||||
// 게시판 이름 (ppomppu8 → 알리뽐뿌)
|
// 게시판 이름
|
||||||
Text(
|
Text(
|
||||||
text = deal.boardDisplayName,
|
text = deal.boardDisplayName,
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
|
||||||
|
// 키워드 매칭 배지 (있을 때만)
|
||||||
if (deal.isKeywordMatch) {
|
if (deal.isKeywordMatch) {
|
||||||
Spacer(modifier = Modifier.width(Spacing.xs))
|
Surface(
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.height(26.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
|
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||||
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Star,
|
imageVector = Icons.Default.NotificationsActive,
|
||||||
contentDescription = "키워드 매칭",
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = MaterialTheme.colorScheme.onPrimary,
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "키워드 매칭",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onPrimary
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Spacer(modifier = Modifier.weight(1f))
|
Spacer(modifier = Modifier.width(Spacing.xs))
|
||||||
|
}
|
||||||
|
|
||||||
// 공유 버튼
|
// 공유 버튼
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -139,10 +183,14 @@ fun DealItem(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(Spacing.sm))
|
Spacer(modifier = Modifier.height(Spacing.sm))
|
||||||
|
|
||||||
// 제목
|
// 제목 (키워드 매칭 시 굵게)
|
||||||
Text(
|
Text(
|
||||||
text = deal.title,
|
text = deal.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = if (deal.isKeywordMatch) {
|
||||||
|
MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold)
|
||||||
|
} else {
|
||||||
|
MaterialTheme.typography.bodyLarge
|
||||||
|
},
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.hotdeal.alarm.data.local.db.dao.HotDealDao
|
|||||||
import com.hotdeal.alarm.data.local.db.dao.KeywordDao
|
import com.hotdeal.alarm.data.local.db.dao.KeywordDao
|
||||||
import com.hotdeal.alarm.data.local.db.dao.SiteConfigDao
|
import com.hotdeal.alarm.data.local.db.dao.SiteConfigDao
|
||||||
import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity
|
import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity
|
||||||
|
import com.hotdeal.alarm.data.local.preferences.AppSettings
|
||||||
import com.hotdeal.alarm.domain.model.SiteType
|
import com.hotdeal.alarm.domain.model.SiteType
|
||||||
import com.hotdeal.alarm.worker.WorkerScheduler
|
import com.hotdeal.alarm.worker.WorkerScheduler
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -18,12 +19,17 @@ class MainViewModel @Inject constructor(
|
|||||||
private val hotDealDao: HotDealDao,
|
private val hotDealDao: HotDealDao,
|
||||||
private val siteConfigDao: SiteConfigDao,
|
private val siteConfigDao: SiteConfigDao,
|
||||||
private val keywordDao: KeywordDao,
|
private val keywordDao: KeywordDao,
|
||||||
private val workerScheduler: WorkerScheduler
|
private val workerScheduler: WorkerScheduler,
|
||||||
|
private val appSettings: AppSettings
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _uiState = MutableStateFlow<MainUiState>(MainUiState.Loading)
|
private val _uiState = MutableStateFlow<MainUiState>(MainUiState.Loading)
|
||||||
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
|
// 폴<> 주기
|
||||||
|
val pollingInterval: StateFlow<Int> = appSettings.pollingInterval
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), 2)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
initializeApp()
|
initializeApp()
|
||||||
}
|
}
|
||||||
@@ -32,6 +38,9 @@ class MainViewModel @Inject constructor(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
initializeDefaultSiteConfigs()
|
initializeDefaultSiteConfigs()
|
||||||
loadState()
|
loadState()
|
||||||
|
// 저장된 폴<> 주기로 시작
|
||||||
|
val savedInterval = appSettings.pollingInterval.first()
|
||||||
|
startPolling(savedInterval.toLong())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,9 +114,15 @@ class MainViewModel @Inject constructor(
|
|||||||
workerScheduler.executeOnce()
|
workerScheduler.executeOnce()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 폴<> 시작 (주기 저장)
|
||||||
|
*/
|
||||||
fun startPolling(intervalMinutes: Long = WorkerScheduler.DEFAULT_INTERVAL_MINUTES) {
|
fun startPolling(intervalMinutes: Long = WorkerScheduler.DEFAULT_INTERVAL_MINUTES) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
appSettings.setPollingInterval(intervalMinutes.toInt())
|
||||||
workerScheduler.schedulePeriodicPolling(intervalMinutes)
|
workerScheduler.schedulePeriodicPolling(intervalMinutes)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun stopPolling() {
|
fun stopPolling() {
|
||||||
workerScheduler.cancelPolling()
|
workerScheduler.cancelPolling()
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ 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.util.VersionManager
|
||||||
import com.hotdeal.alarm.worker.WorkerScheduler
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -35,6 +34,7 @@ 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()
|
val scope = rememberCoroutineScope()
|
||||||
|
val currentPollingInterval by viewModel.pollingInterval.collectAsState()
|
||||||
|
|
||||||
var showPermissionDialog by remember { mutableStateOf(false) }
|
var showPermissionDialog by remember { mutableStateOf(false) }
|
||||||
var permissionDialogTitle by remember { mutableStateOf("") }
|
var permissionDialogTitle by remember { mutableStateOf("") }
|
||||||
@@ -47,28 +47,23 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
if (!isGranted) {
|
if (!isGranted) {
|
||||||
permissionDialogTitle = "알림 권한 필요"
|
permissionDialogTitle = "알림 권한 필요"
|
||||||
permissionDialogMessage = "핫딜 알림을 받으려면 알림 권한이 필요합니다."
|
permissionDialogMessage = "핫딜 알림을 받으려면 알림 권한이 필요합니다."
|
||||||
permissionDialogAction = {
|
permissionDialogAction = { PermissionHelper.openNotificationSettings(context) }
|
||||||
PermissionHelper.openNotificationSettings(context)
|
|
||||||
}
|
|
||||||
showPermissionDialog = true
|
showPermissionDialog = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp)
|
verticalArrangement = Arrangement.spacedBy(16.dp)
|
||||||
) {
|
) {
|
||||||
item {
|
item {
|
||||||
PermissionStatusSection(
|
PermissionCard(
|
||||||
permissionStatus = permissionStatus,
|
permissionStatus = permissionStatus,
|
||||||
hasEnabledSites = hasEnabledSites,
|
hasEnabledSites = hasEnabledSites,
|
||||||
onRequestNotificationPermission = {
|
onRequestPermission = {
|
||||||
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)
|
||||||
}
|
}
|
||||||
@@ -77,29 +72,26 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
PollingIntervalSection(
|
PollingIntervalCard(
|
||||||
currentInterval = 2,
|
currentInterval = currentPollingInterval,
|
||||||
onIntervalChange = { minutes ->
|
onIntervalChange = { minutes ->
|
||||||
viewModel.stopPolling()
|
viewModel.stopPolling()
|
||||||
viewModel.startPolling(minutes)
|
viewModel.startPolling(minutes)
|
||||||
|
Toast.makeText(context, "${minutes}분으로 변경됨", Toast.LENGTH_SHORT).show()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item { Text("사이트 선택", style = MaterialTheme.typography.titleMedium) }
|
||||||
Text("사이트 선택", style = MaterialTheme.typography.titleMedium)
|
|
||||||
}
|
|
||||||
|
|
||||||
when (val state = uiState) {
|
when (val state = uiState) {
|
||||||
is MainUiState.Success -> {
|
is MainUiState.Success -> {
|
||||||
SiteType.entries.forEach { site ->
|
SiteType.entries.forEach { site ->
|
||||||
item {
|
item {
|
||||||
SiteSection(
|
SiteCard(
|
||||||
siteType = site,
|
siteType = site,
|
||||||
configs = state.siteConfigs.filter { it.siteName == site.name },
|
configs = state.siteConfigs.filter { it.siteName == site.name },
|
||||||
onToggle = { key, enabled ->
|
onToggle = { key, enabled -> viewModel.toggleSiteConfig(key, enabled) }
|
||||||
viewModel.toggleSiteConfig(key, enabled)
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -111,10 +103,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
|
|
||||||
item {
|
item {
|
||||||
var keywordText by remember { mutableStateOf("") }
|
var keywordText by remember { mutableStateOf("") }
|
||||||
Row(
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = keywordText,
|
value = keywordText,
|
||||||
onValueChange = { keywordText = it },
|
onValueChange = { keywordText = it },
|
||||||
@@ -144,16 +133,14 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 버전 정보 섹션
|
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
VersionSection(
|
VersionCard(
|
||||||
currentVersion = VersionManager.getCurrentVersion(context),
|
currentVersion = VersionManager.getCurrentVersion(context),
|
||||||
onCheckUpdate = {
|
onCheckUpdate = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
val currentCode = VersionManager.getCurrentVersionCode(context)
|
val currentCode = VersionManager.getCurrentVersionCode(context)
|
||||||
val remoteInfo = VersionManager.checkForUpdate()
|
val remoteInfo = VersionManager.checkForUpdate()
|
||||||
|
|
||||||
if (remoteInfo != null && VersionManager.isUpdateAvailable(currentCode, remoteInfo.versionCode)) {
|
if (remoteInfo != null && VersionManager.isUpdateAvailable(currentCode, remoteInfo.versionCode)) {
|
||||||
Toast.makeText(context, "새 버전 ${remoteInfo.version} 사용 가능", Toast.LENGTH_LONG).show()
|
Toast.makeText(context, "새 버전 ${remoteInfo.version} 사용 가능", Toast.LENGTH_LONG).show()
|
||||||
} else {
|
} else {
|
||||||
@@ -164,9 +151,7 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> { item { CircularProgressIndicator() } }
|
||||||
item { CircularProgressIndicator() }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,33 +166,47 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun VersionSection(currentVersion: String, onCheckUpdate: () -> Unit) {
|
fun PermissionCard(
|
||||||
|
permissionStatus: PermissionHelper.PermissionStatus,
|
||||||
|
hasEnabledSites: Boolean,
|
||||||
|
onRequestPermission: () -> Unit
|
||||||
|
) {
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Row(
|
Text("알림 설정", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
Icon(
|
||||||
) {
|
imageVector = if (permissionStatus.hasNotificationPermission) Icons.Default.Check else Icons.Default.Close,
|
||||||
Column {
|
contentDescription = null,
|
||||||
Text("앱 버전", style = MaterialTheme.typography.labelMedium)
|
tint = if (permissionStatus.hasNotificationPermission) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||||
Text("v$currentVersion", style = MaterialTheme.typography.titleMedium)
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Text("알림 권한", modifier = Modifier.weight(1f))
|
||||||
|
if (!permissionStatus.hasNotificationPermission) {
|
||||||
|
Button(onClick = onRequestPermission) { Text("설정") }
|
||||||
}
|
}
|
||||||
Button(onClick = onCheckUpdate) {
|
|
||||||
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("사이트 선택")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PollingIntervalSection(currentInterval: Int, onIntervalChange: (Long) -> Unit) {
|
fun PollingIntervalCard(currentInterval: Int, onIntervalChange: (Long) -> Unit) {
|
||||||
var selected by remember { mutableStateOf(currentInterval) }
|
var selected by remember(currentInterval) { mutableStateOf(currentInterval) }
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text("폴<EFBFBD> 주기", style = MaterialTheme.typography.titleMedium)
|
Text("폴<EFBFBD> 주기", style = MaterialTheme.typography.titleMedium)
|
||||||
Text("2분 권장 (배터리/데이터 절약)", style = MaterialTheme.typography.bodySmall)
|
Text("2분 권장", style = MaterialTheme.typography.bodySmall)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
listOf(1, 2, 5, 10, 15, 30).forEach { minutes ->
|
listOf(1, 2, 5, 10, 15, 30).forEach { minutes ->
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
@@ -234,44 +233,7 @@ fun PollingIntervalSection(currentInterval: Int, onIntervalChange: (Long) -> Uni
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PermissionStatusSection(
|
fun SiteCard(siteType: SiteType, configs: List<SiteConfig>, onToggle: (String, Boolean) -> Unit) {
|
||||||
permissionStatus: PermissionHelper.PermissionStatus,
|
|
||||||
hasEnabledSites: Boolean,
|
|
||||||
onRequestNotificationPermission: () -> Unit
|
|
||||||
) {
|
|
||||||
Card(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text("알림 설정", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
|
||||||
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()) {
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
Text(siteType.displayName, style = MaterialTheme.typography.titleSmall)
|
Text(siteType.displayName, style = MaterialTheme.typography.titleSmall)
|
||||||
@@ -305,3 +267,22 @@ fun KeywordItem(keyword: Keyword, onToggle: () -> Unit, onDelete: () -> Unit) {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun VersionCard(currentVersion: String, onCheckUpdate: () -> Unit) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text("앱 버전", style = MaterialTheme.typography.labelMedium)
|
||||||
|
Text("v$currentVersion", style = MaterialTheme.typography.titleMedium)
|
||||||
|
}
|
||||||
|
Button(onClick = onCheckUpdate) { Text("업데이트 확인") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import kotlinx.coroutines.withContext
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 핫딜 폴<> Worker
|
* 핫딜 폴<> Worker
|
||||||
|
*
|
||||||
|
* 키워드 매칭된 핫딜만 알림 발송
|
||||||
*/
|
*/
|
||||||
@HiltWorker
|
@HiltWorker
|
||||||
class HotDealPollingWorker @AssistedInject constructor(
|
class HotDealPollingWorker @AssistedInject constructor(
|
||||||
@@ -47,20 +49,13 @@ class HotDealPollingWorker @AssistedInject constructor(
|
|||||||
Log.d(TAG, "활성화된 사이트 설정: ${enabledConfigs.size}개")
|
Log.d(TAG, "활성화된 사이트 설정: ${enabledConfigs.size}개")
|
||||||
|
|
||||||
if (enabledConfigs.isEmpty()) {
|
if (enabledConfigs.isEmpty()) {
|
||||||
Log.w(TAG, "활성화된 사이트 설정이 없습니다. 폴<>을 건<>뜁니다.")
|
Log.w(TAG, "활성화된 사이트 설정이 없습니다.")
|
||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
enabledConfigs.forEach { config ->
|
|
||||||
Log.d(TAG, " - 사이트: ${config.siteName}, 게시판: ${config.boardName}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 활성화된 키워드 조회
|
// 2. 활성화된 키워드 조회
|
||||||
val keywords = keywordDao.getEnabledKeywords()
|
val keywords = keywordDao.getEnabledKeywords()
|
||||||
Log.d(TAG, "활성화된 키워드: ${keywords.size}개")
|
Log.d(TAG, "활성화된 키워드: ${keywords.size}개")
|
||||||
keywords.forEach { kw ->
|
|
||||||
Log.d(TAG, " - 키워드: ${kw.keyword}")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 각 사이트/게시판 스크래핑 (병렬)
|
// 3. 각 사이트/게시판 스크래핑 (병렬)
|
||||||
Log.d(TAG, "스크래핑 시작...")
|
Log.d(TAG, "스크래핑 시작...")
|
||||||
@@ -74,11 +69,10 @@ class HotDealPollingWorker @AssistedInject constructor(
|
|||||||
|
|
||||||
if (allDeals.isEmpty()) {
|
if (allDeals.isEmpty()) {
|
||||||
Log.w(TAG, "스크래핑된 핫딜이 없습니다.")
|
Log.w(TAG, "스크래핑된 핫딜이 없습니다.")
|
||||||
// 스크래핑은 성공했지만 결과가 없는 경우도 success로 처리
|
|
||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. 새로운 핫딜 필터링 (URL과 ID 모두로 중복 체크)
|
// 4. 새로운 핫딜 필터링
|
||||||
val newDeals = filterNewDeals(allDeals)
|
val newDeals = filterNewDeals(allDeals)
|
||||||
Log.d(TAG, "새로운 핫딜: ${newDeals.size}개")
|
Log.d(TAG, "새로운 핫딜: ${newDeals.size}개")
|
||||||
|
|
||||||
@@ -87,15 +81,17 @@ class HotDealPollingWorker @AssistedInject constructor(
|
|||||||
return@withContext Result.success()
|
return@withContext Result.success()
|
||||||
}
|
}
|
||||||
|
|
||||||
newDeals.take(5).forEach { deal ->
|
// 5. 키워드 매칭 (키워드가 있을 때만)
|
||||||
Log.d(TAG, " 새 핫딜: ${deal.title.take(50)}...")
|
val keywordMatchedDeals = if (keywords.isNotEmpty()) {
|
||||||
|
filterByKeywords(newDeals, keywords).also {
|
||||||
|
Log.d(TAG, "키워드 매칭 핫딜: ${it.size}개")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "키워드가 없어 매칭 체크를 건<>뜁니다.")
|
||||||
|
emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. 키워드 매칭
|
// 6. DB 저장
|
||||||
val keywordMatchedDeals = filterByKeywords(newDeals, keywords)
|
|
||||||
Log.d(TAG, "키워드 매칭 핫딜: ${keywordMatchedDeals.size}개")
|
|
||||||
|
|
||||||
// 6. DB 저장 (개별 삽입으로 변경하여 충돌 처리 개선)
|
|
||||||
var insertedCount = 0
|
var insertedCount = 0
|
||||||
newDeals.forEach { deal ->
|
newDeals.forEach { deal ->
|
||||||
try {
|
try {
|
||||||
@@ -103,19 +99,18 @@ class HotDealPollingWorker @AssistedInject constructor(
|
|||||||
dealDao.insertDeal(entity)
|
dealDao.insertDeal(entity)
|
||||||
insertedCount++
|
insertedCount++
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.w(TAG, "핫딜 저장 실패 (무시됨): ${deal.id}, 오류: ${e.message}")
|
Log.w(TAG, "핫딜 저장 실패 (무시됨): ${deal.id}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Log.d(TAG, "DB 저장 완료: $insertedCount/${newDeals.size}개 저장됨")
|
Log.d(TAG, "DB 저장 완료: $insertedCount/${newDeals.size}개 저장됨")
|
||||||
|
|
||||||
// 7. 알림 발송
|
// 7. 키워드 매칭 알림 발송 (키워드 매칭된 것만!)
|
||||||
if (keywordMatchedDeals.isNotEmpty()) {
|
if (keywordMatchedDeals.isNotEmpty()) {
|
||||||
Log.d(TAG, "키워드 매칭 알림 발송...")
|
Log.d(TAG, "키워드 매칭 알림 발송: ${keywordMatchedDeals.size}개")
|
||||||
notificationService.showKeywordMatchNotification(keywordMatchedDeals)
|
notificationService.showKeywordMatchNotification(keywordMatchedDeals)
|
||||||
dealDao.markAsKeywordMatch(keywordMatchedDeals.map { it.id })
|
dealDao.markAsKeywordMatch(keywordMatchedDeals.map { it.id })
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "새 핫딜 알림 발송...")
|
Log.d(TAG, "키워드 매칭된 핫딜이 없어 알림을 발송하지 않습니다.")
|
||||||
notificationService.showNewDealsNotification(newDeals.take(5))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. 알림 발송 상태 업데이트
|
// 8. 알림 발송 상태 업데이트
|
||||||
@@ -124,7 +119,6 @@ class HotDealPollingWorker @AssistedInject constructor(
|
|||||||
// 9. 오래된 데이터 정리 (7일 이상)
|
// 9. 오래된 데이터 정리 (7일 이상)
|
||||||
val threshold = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000L)
|
val threshold = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000L)
|
||||||
dealDao.deleteOldDeals(threshold)
|
dealDao.deleteOldDeals(threshold)
|
||||||
Log.d(TAG, "오래된 데이터 정리 완료")
|
|
||||||
|
|
||||||
Log.d(TAG, "===== 핫딜 폴<> 완료 =====")
|
Log.d(TAG, "===== 핫딜 폴<> 완료 =====")
|
||||||
Result.success()
|
Result.success()
|
||||||
@@ -138,16 +132,10 @@ class HotDealPollingWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 포그라운드 서비스 정보 (빈번한 폴<>용)
|
|
||||||
*/
|
|
||||||
override suspend fun getForegroundInfo(): ForegroundInfo {
|
override suspend fun getForegroundInfo(): ForegroundInfo {
|
||||||
return notificationService.createForegroundInfo()
|
return notificationService.createForegroundInfo()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 사이트 스크래핑
|
|
||||||
*/
|
|
||||||
private suspend fun scrapeSite(config: SiteConfigEntity): List<HotDeal> {
|
private suspend fun scrapeSite(config: SiteConfigEntity): List<HotDeal> {
|
||||||
val scraper = scraperFactory.getScraper(config.siteName)
|
val scraper = scraperFactory.getScraper(config.siteName)
|
||||||
if (scraper == null) {
|
if (scraper == null) {
|
||||||
@@ -161,7 +149,6 @@ class HotDealPollingWorker @AssistedInject constructor(
|
|||||||
result.fold(
|
result.fold(
|
||||||
onSuccess = { deals ->
|
onSuccess = { deals ->
|
||||||
Log.d(TAG, " ${config.siteName}: ${deals.size}개 핫딜 파싱됨")
|
Log.d(TAG, " ${config.siteName}: ${deals.size}개 핫딜 파싱됨")
|
||||||
// 스크래핑 성공 시 lastScrapedAt 업데이트
|
|
||||||
try {
|
try {
|
||||||
siteConfigDao.updateLastScrapedAt(config.siteBoardKey, System.currentTimeMillis())
|
siteConfigDao.updateLastScrapedAt(config.siteBoardKey, System.currentTimeMillis())
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -176,17 +163,11 @@ class HotDealPollingWorker @AssistedInject constructor(
|
|||||||
return result.getOrElse { emptyList() }
|
return result.getOrElse { emptyList() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 새로운 핫딜 필터링
|
|
||||||
* URL과 ID 모두로 중복 체크
|
|
||||||
*/
|
|
||||||
private suspend fun filterNewDeals(deals: List<HotDeal>): List<HotDeal> {
|
private suspend fun filterNewDeals(deals: List<HotDeal>): List<HotDeal> {
|
||||||
return deals.filter { deal ->
|
return deals.filter { deal ->
|
||||||
// URL로 먼저 체크
|
|
||||||
val existingByUrl = dealDao.getDealByUrl(deal.url)
|
val existingByUrl = dealDao.getDealByUrl(deal.url)
|
||||||
if (existingByUrl != null) return@filter false
|
if (existingByUrl != null) return@filter false
|
||||||
|
|
||||||
// ID로도 체크 (추가 안전장치)
|
|
||||||
val existingById = dealDao.getDealById(deal.id)
|
val existingById = dealDao.getDealById(deal.id)
|
||||||
if (existingById != null) return@filter false
|
if (existingById != null) return@filter false
|
||||||
|
|
||||||
@@ -194,15 +175,10 @@ class HotDealPollingWorker @AssistedInject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun filterByKeywords(deals: List<HotDeal>, keywords: List<KeywordEntity>): List<HotDeal> {
|
||||||
* 키워드 매칭 필터링
|
|
||||||
*/
|
|
||||||
private suspend fun filterByKeywords(deals: List<HotDeal>, keywords: List<KeywordEntity>): List<HotDeal> {
|
|
||||||
if (keywords.isEmpty()) return emptyList()
|
|
||||||
|
|
||||||
return deals.filter { deal ->
|
return deals.filter { deal ->
|
||||||
keywords.any { keyword ->
|
keywords.any { keyword ->
|
||||||
keyword.toDomain().matches(deal.title)
|
deal.title.contains(keyword.keyword, ignoreCase = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user