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:
sanjeok77
2026-03-04 01:57:25 +09:00
parent c22cfafe88
commit f9b4a4a90f
6 changed files with 214 additions and 145 deletions

View File

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

View File

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

View File

@@ -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,25 +114,43 @@ 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
) )
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)) 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( IconButton(
onClick = { ShareHelper.shareDeal(context, deal) }, onClick = { ShareHelper.shareDeal(context, deal) },
@@ -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

View File

@@ -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,8 +114,14 @@ class MainViewModel @Inject constructor(
workerScheduler.executeOnce() workerScheduler.executeOnce()
} }
/**
* 폴<> 시작 (주기 저장)
*/
fun startPolling(intervalMinutes: Long = WorkerScheduler.DEFAULT_INTERVAL_MINUTES) { fun startPolling(intervalMinutes: Long = WorkerScheduler.DEFAULT_INTERVAL_MINUTES) {
workerScheduler.schedulePeriodicPolling(intervalMinutes) viewModelScope.launch {
appSettings.setPollingInterval(intervalMinutes.toInt())
workerScheduler.schedulePeriodicPolling(intervalMinutes)
}
} }
fun stopPolling() { fun stopPolling() {

View File

@@ -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))
Button(onClick = onCheckUpdate) { Text("알림 권한", modifier = Modifier.weight(1f))
Text("업데이트 확인") 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 @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("업데이트 확인") }
}
}
}
}

View File

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