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