113 Commits
Author SHA1 Message Date
sanjeok77 4ae92589ca v0.3.5: update proguard-rules.pro 2026-08-25 02:11:18 +00:00
sanjeok77 95236893b6 v0.3.5: update AndroidManifest.xml 2026-08-25 02:11:16 +00:00
sanjeok77 d03e54ce8c v0.3.5: update PermissionHelper.kt 2026-08-25 02:11:15 +00:00
sanjeok77 6b40f81146 v0.3.5: update SettingsScreen.kt 2026-08-25 02:11:13 +00:00
sanjeok77 bd3fcf42f6 v0.3.5: update DealListScreen.kt 2026-08-25 02:11:11 +00:00
sanjeok77 c9213d4b2f v0.3.5: update MainActivity.kt 2026-08-25 02:11:10 +00:00
sanjeok77 9785f55b81 v0.3.5: update MainViewModel.kt 2026-08-25 02:11:08 +00:00
sanjeok77 af33f0d02b v0.3.5: update WorkerScheduler.kt 2026-08-25 02:11:06 +00:00
sanjeok77 88a8d6095e v0.3.5: update HotDealPollingWorker.kt 2026-08-25 02:11:05 +00:00
sanjeok77 986e50088b v0.3.5: update PollingAlarmReceiver.kt 2026-08-25 02:11:03 +00:00
sanjeok77 34ba7b5a0f v0.3.5: add HotDealScraperManager.kt 2026-08-25 02:11:02 +00:00
sanjeok77 9e0a9d7476 v0.3.5: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-25 02:10:51 +00:00
sanjeok77 bde24a319e v0.3.5: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-25 02:10:50 +00:00
sanjeok77 13af6b1c7c v0.3.5: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-25 02:10:48 +00:00
sanjeok77 b4db18662f v0.3.5: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-25 02:10:46 +00:00
sanjeok77 01c289e35c v0.3.5: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-25 02:10:44 +00:00
sanjeok77 68ee7002ca v0.3.4: update proguard-rules.pro 2026-08-25 01:58:45 +00:00
sanjeok77 36ea6bc189 v0.3.4: update AndroidManifest.xml 2026-08-25 01:58:43 +00:00
sanjeok77 cf2d7c615a v0.3.4: update PpomppuScraper.kt 2026-08-25 01:58:42 +00:00
sanjeok77 0597a002cd v0.3.4: update MainViewModel.kt 2026-08-25 01:58:40 +00:00
sanjeok77 b515156ff1 v0.3.4: update HotDealApplication.kt 2026-08-25 01:58:38 +00:00
sanjeok77 2dfa766b95 v0.3.4: update WorkerScheduler.kt 2026-08-25 01:58:36 +00:00
sanjeok77 5c992f15e6 v0.3.4: update BootReceiver.kt 2026-08-25 01:58:35 +00:00
sanjeok77 3fe0121bef v0.3.4: add PollingAlarmReceiver.kt 2026-08-25 01:58:33 +00:00
sanjeok77 5de2b56c04 v0.3.4: add AlarmScheduler.kt 2026-08-25 01:58:31 +00:00
sanjeok77 5f8299660b v0.3.4: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-25 01:58:23 +00:00
sanjeok77 7e4002ab04 v0.3.4: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-25 01:58:21 +00:00
sanjeok77 f59881f396 v0.3.4: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-25 01:58:20 +00:00
sanjeok77 0fc5dee5a4 v0.3.4: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-25 01:58:18 +00:00
sanjeok77 b7712875f9 v0.3.4: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-25 01:58:16 +00:00
sanjeok77 a4a6492945 v0.3.3: fix dead code in initializeApp - split coroutines 2026-08-24 23:02:06 +00:00
sanjeok77 984cc0bd85 v0.3.3: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-24 23:01:46 +00:00
sanjeok77 3bec5eeca3 v0.3.3: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-24 23:01:44 +00:00
sanjeok77 d6c04312e0 v0.3.3: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-24 23:01:42 +00:00
sanjeok77 c52c93694e v0.3.3: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-24 23:01:41 +00:00
sanjeok77 5390a1db3f v0.3.3: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-24 23:01:39 +00:00
sanjeok77 ad4cdef4cb v0.3.2: new BootReceiver.kt 2026-08-24 22:34:34 +00:00
sanjeok77 ea79599c08 v0.3.2: AndroidManifest.xml 2026-08-24 22:34:33 +00:00
sanjeok77 e82725887b v0.3.2: MainViewModel.kt 2026-08-24 22:34:31 +00:00
sanjeok77 bd4d6a35ff v0.3.2: DealListScreen.kt 2026-08-24 22:34:29 +00:00
sanjeok77 e332738eff v0.3.2: HotDealDao.kt 2026-08-24 22:34:27 +00:00
sanjeok77 aae7836e89 v0.3.2: PpomppuScraper.kt 2026-08-24 22:34:26 +00:00
sanjeok77 945f5146ed v0.3.2: HotDealPollingWorker.kt 2026-08-24 22:34:24 +00:00
sanjeok77 bf4037d933 v0.3.2: WorkerScheduler.kt 2026-08-24 22:34:22 +00:00
sanjeok77 47de6bad98 v0.3.2: HotDealApplication.kt 2026-08-24 22:34:20 +00:00
sanjeok77 cd866600e6 v0.3.2: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-24 22:32:25 +00:00
sanjeok77 2b51518d0e v0.3.2: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-24 22:32:24 +00:00
sanjeok77 2e5f6d1ba5 v0.3.2: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-24 22:32:22 +00:00
sanjeok77 204faf0186 v0.3.2: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-24 22:32:20 +00:00
sanjeok77 0cf08466b6 v0.3.2: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-24 22:32:18 +00:00
sanjeok77 19008da9c6 v0.3.1: MainViewModel.kt 2026-08-21 00:56:11 +00:00
sanjeok77 ffaefcdddd v0.3.1: DealListScreen.kt 2026-08-21 00:56:10 +00:00
sanjeok77 8ec2e61f66 v0.3.1: HotDealPollingWorker.kt 2026-08-21 00:56:08 +00:00
sanjeok77 c0574d7777 v0.3.1: HotDealDao.kt 2026-08-21 00:56:06 +00:00
sanjeok77 d7a8045038 v0.3.1: PpomppuScraper.kt 2026-08-21 00:56:04 +00:00
sanjeok77 8c55eba1b9 v0.3.1: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-21 00:55:41 +00:00
sanjeok77 b97ade7996 v0.3.1: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-21 00:55:40 +00:00
sanjeok77 833d760695 v0.3.1: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-21 00:55:38 +00:00
sanjeok77 d5677ce4c9 v0.3.1: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-21 00:55:36 +00:00
sanjeok77 60501e86da v0.3.1: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-21 00:55:35 +00:00
sanjeok77 6f69c39ff8 fix: synchronous reorder for keywords and sites (v0.3.0) 2026-08-21 00:27:17 +00:00
sanjeok77 fb1e8b71d3 v0.3.0: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-21 00:26:49 +00:00
sanjeok77 59037c8329 v0.3.0: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-21 00:26:47 +00:00
sanjeok77 e68d7f158f v0.3.0: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-21 00:26:46 +00:00
sanjeok77 57f41f1216 v0.3.0: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-21 00:26:44 +00:00
sanjeok77 18e458d340 v0.3.0: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-21 00:26:42 +00:00
sanjeok77 e06e5b744a v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-20 23:59:48 +00:00
sanjeok77 17efbe80db v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-20 23:59:46 +00:00
sanjeok77 6c55b9eeec v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-20 23:59:44 +00:00
sanjeok77 5d610eab02 v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-20 23:59:43 +00:00
sanjeok77 9fd97ae337 v0.2.9: True Touch Drag & Drop Reordering & Fix OTA Auto-Install Bug 2026-08-20 23:59:41 +00:00
sanjeok77 0bce4ece00 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:31 +00:00
sanjeok77 a37e07d764 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:29 +00:00
sanjeok77 8f2f5b29d3 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:28 +00:00
sanjeok77 5c272a76e7 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:26 +00:00
sanjeok77 7ae5f713d6 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:24 +00:00
sanjeok77 48e81598b4 v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:23 +00:00
sanjeok77 436d494a6d v0.2.8: Site & Keyword Reordering Support & Slim Bottom Bar Margins 2026-08-20 23:53:21 +00:00
sanjeok77 3ebd828d8d v0.2.7: Seamless Docked Bottom Navigation Bar Redesign 2026-08-20 23:43:16 +00:00
sanjeok77 93e5562244 v0.2.7: Seamless Docked Bottom Navigation Bar Redesign 2026-08-20 23:43:15 +00:00
sanjeok77 dc07fc6b2b v0.2.7: Seamless Docked Bottom Navigation Bar Redesign 2026-08-20 23:43:13 +00:00
sanjeok77 2602a18d1b v0.2.6: Maximize Main Screen Content Density & In-AppBar Expandable Search 2026-08-20 23:38:52 +00:00
sanjeok77 73c5c727e7 v0.2.6: Maximize Main Screen Content Density & In-AppBar Expandable Search 2026-08-20 23:38:50 +00:00
sanjeok77 34a33426cd v0.2.6: Maximize Main Screen Content Density & In-AppBar Expandable Search 2026-08-20 23:38:49 +00:00
sanjeok77 c1259127f5 v0.2.6: Maximize Main Screen Content Density & In-AppBar Expandable Search 2026-08-20 23:38:47 +00:00
sanjeok77 ec8fedb073 v0.2.5: One UI 9 & Material 3 Expressive UI/UX Major Redesign 2026-08-20 23:31:19 +00:00
sanjeok77 6780576462 v0.2.5: One UI 9 & Material 3 Expressive UI/UX Major Redesign 2026-08-20 23:31:17 +00:00
sanjeok77 001e4691f7 v0.2.5: One UI 9 & Material 3 Expressive UI/UX Major Redesign 2026-08-20 23:31:15 +00:00
sanjeok77 cd79a0c97b v0.2.5: One UI 9 & Material 3 Expressive UI/UX Major Redesign 2026-08-20 23:31:14 +00:00
sanjeok77 2b2b740f5b v0.2.5: One UI 9 & Material 3 Expressive UI/UX Major Redesign 2026-08-20 23:31:12 +00:00
sanjeok77 9a50b31228 v0.2.5: One UI 9 & Material 3 Expressive UI/UX Major Redesign 2026-08-20 23:31:10 +00:00
sanjeok77 5ab42c0f62 v0.2.5: One UI 9 & Material 3 Expressive UI/UX Major Redesign 2026-08-20 23:31:09 +00:00
sanjeok77 c071021001 v0.2.5: One UI 9 & Material 3 Expressive UI/UX Major Redesign 2026-08-20 23:31:07 +00:00
sanjeok77 5155a642ca v0.2.5: One UI 9 & Material 3 Expressive UI/UX Major Redesign 2026-08-20 23:31:05 +00:00
sanjeok77 d4319b83cb v0.2.5: One UI 9 & Material 3 Expressive UI/UX Major Redesign 2026-08-20 23:31:04 +00:00
sanjeok77 6e6747d1bb v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign 2026-08-20 23:29:01 +00:00
sanjeok77 da4ad8d3a3 v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign 2026-08-20 23:28:59 +00:00
sanjeok77 7c311d7d3f v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign 2026-08-20 23:28:58 +00:00
sanjeok77 b070d317c5 v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign 2026-08-20 23:28:56 +00:00
sanjeok77 51c2ca9d72 v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign 2026-08-20 23:28:54 +00:00
sanjeok77 779ddb497d v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign 2026-08-20 23:28:53 +00:00
sanjeok77 ef55fae424 v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign 2026-08-20 23:28:51 +00:00
sanjeok77 54abc8b535 v0.2.4: One UI 9 & Material 3 Expressive UI/UX Redesign 2026-08-20 23:28:49 +00:00
sanjeok77 c53962c3c5 v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter 2026-08-20 23:16:37 +00:00
sanjeok77 ff18515749 v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter 2026-08-20 23:16:36 +00:00
sanjeok77 ead4965247 v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter 2026-08-20 23:16:34 +00:00
sanjeok77 1cb1c1cbca v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter 2026-08-20 23:16:32 +00:00
sanjeok77 c7efc6959c v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter 2026-08-20 23:16:30 +00:00
sanjeok77 a3fe76326e v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter 2026-08-20 23:16:29 +00:00
sanjeok77 b1f840b482 v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter 2026-08-20 23:16:27 +00:00
sanjeok77 0e16a3b6e6 v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter 2026-08-20 23:16:25 +00:00
sanjeok77 b31b6f0a27 v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter 2026-08-20 23:16:24 +00:00
sanjeok77 952c373570 v0.2.4: Add ArcaLive (아카라이브) hotdeal support & settings toggle & main filter 2026-08-20 23:16:22 +00:00
32 changed files with 2531 additions and 1885 deletions

No files matched your search

+2 -2
View File
@@ -24,8 +24,8 @@ android {
applicationId = "com.hotdeal.alarm"
minSdk = 31
targetSdk = 35
versionCode = 23
versionName = "1.11.6"
versionCode = 35
versionName = "0.3.5"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
+32 -1
View File
@@ -1,8 +1,40 @@
# Add project specific ProGuard rules here.
-keepattributes Signature
-keepattributes *Annotation*
# Data Entities & Models
-keep class com.hotdeal.alarm.data.local.db.entity.** { *; }
-keep class com.hotdeal.alarm.domain.model.** { *; }
-keep class com.hotdeal.alarm.data.local.preferences.** { *; }
# Room Database & DAOs
-keep class * extends androidx.room.RoomDatabase
-keep class com.hotdeal.alarm.data.local.db.dao.** { *; }
-dontwarn androidx.room.paging.**
# WorkManager & Hilt Worker
-keep class androidx.work.** { *; }
-keep class androidx.hilt.work.** { *; }
-keep class com.hotdeal.alarm.worker.** { *; }
-keep class * extends androidx.work.ListenableWorker {
public <init>(android.content.Context, androidx.work.WorkerParameters);
}
-keep class * extends androidx.work.Worker {
public <init>(android.content.Context, androidx.work.WorkerParameters);
}
-keep class * extends androidx.work.CoroutineWorker {
public <init>(android.content.Context, androidx.work.WorkerParameters);
}
# Dagger & Hilt
-keep class com.hotdeal.alarm.di.** { *; }
-keep class * extends android.app.Application
-keep class * extends android.app.Service
-keep class * extends android.content.BroadcastReceiver
-keep class com.hotdeal.alarm.receiver.** { *; }
# Scrapers & Remote Data
-keep class com.hotdeal.alarm.data.remote.scraper.** { *; }
# OkHttp
-dontwarn okhttp3.**
@@ -19,4 +51,3 @@
# JSpecify annotations (for Jsoup compatibility)
-dontwarn org.jspecify.annotations.**
+24 -3
View File
@@ -14,8 +14,9 @@
<!-- Notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Exact Alarms (Android 12+) -->
<!-- Exact Alarms (Android 12+, Android 13+) -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
<!-- Boot Completed -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
@@ -65,9 +66,28 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Boot Receiver -->
<!-- Polling Alarm Receiver: 정확한 주기 알람 수신 및 핫딜 스크래핑 트리거 -->
<receiver
android:name=".receiver.PollingAlarmReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.hotdeal.alarm.ACTION_POLL_ALARM" />
</intent-filter>
</receiver>
<!-- Widget -->
<!-- Boot Receiver: 부팅/앱 업데이트 후 백그라운드 폴링 자동 재등록 -->
<receiver
android:name=".receiver.BootReceiver"
android:enabled="true"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<!-- Widget Receiver -->
<receiver
android:name=".widget.HotDealWidget"
android:exported="true">
@@ -84,6 +104,7 @@
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
tools:replace="android:authorities"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
@@ -1,9 +1,17 @@
package com.hotdeal.alarm
import android.app.Application
import android.util.Log
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import com.hotdeal.alarm.data.local.preferences.AppSettings
import com.hotdeal.alarm.worker.WorkerScheduler
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltAndroidApp
@@ -12,13 +20,41 @@ class HotDealApplication : Application(), Configuration.Provider {
@Inject
lateinit var workerFactory: HiltWorkerFactory
@Inject
lateinit var workerScheduler: WorkerScheduler
@Inject
lateinit var appSettings: AppSettings
private val applicationScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
override val workManagerConfiguration: Configuration
get() = Configuration.Builder()
.setWorkerFactory(workerFactory)
.setMinimumLoggingLevel(Log.DEBUG)
.build()
override fun onCreate() {
super.onCreate()
// Application initialization
// 앱 시작 시 백그라운드 폴링 즉시 등록/갱신
ensurePollingScheduled()
}
/**
* 앱이 시작될 때마다 백그라운드 폴링이 등록되어 있는지 확인하고,
* 없으면 즉시 등록합니다. UPDATE 정책으로 항상 최신 Worker가 동작합니다.
*/
private fun ensurePollingScheduled() {
applicationScope.launch {
try {
val interval = appSettings.pollingInterval.first()
Log.d("HotDealApp", "백그라운드 폴링 등록/갱신: ${interval}분 간격")
workerScheduler.schedulePeriodicPolling(interval.toLong())
} catch (e: Exception) {
Log.e("HotDealApp", "폴링 스케줄 등록 실패: ${e.message}", e)
// 기본값으로 폴백
workerScheduler.schedulePeriodicPolling(WorkerScheduler.DEFAULT_INTERVAL_MINUTES)
}
}
}
}
@@ -121,4 +121,10 @@ interface HotDealDao {
*/
@Query("UPDATE hot_deals SET isFavorite = :isFavorite WHERE id = :id")
suspend fun setFavorite(id: String, isFavorite: Boolean)
/**
* 인기글 상태 업데이트 (기존 데이터도 HOT 마킹 가능하도록)
*/
@Query("UPDATE hot_deals SET isPopular = 1 WHERE id IN (:ids)")
suspend fun markAsPopular(ids: List<String>)
}
@@ -5,7 +5,9 @@ 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.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.hotdeal.alarm.domain.model.SiteType
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
@@ -18,11 +20,13 @@ class AppSettings(private val context: Context) {
companion object {
private val POLLING_INTERVAL_KEY = intPreferencesKey("polling_interval_minutes")
private val SITE_ORDER_KEY = stringPreferencesKey("site_order_list")
private val KEYWORD_ORDER_KEY = stringPreferencesKey("keyword_order_list")
private const val DEFAULT_INTERVAL = 2
}
/**
* 폴� 주기 (분)
* 폴링 주기 (분)
*/
val pollingInterval: Flow<Int> = context.dataStore.data
.map { preferences ->
@@ -30,11 +34,72 @@ class AppSettings(private val context: Context) {
}
/**
* 폴� 주기 설정 저장
* 폴링 주기 설정 저장
*/
suspend fun setPollingInterval(minutes: Int) {
context.dataStore.edit { preferences ->
preferences[POLLING_INTERVAL_KEY] = minutes
}
}
/**
* 사이트 표시 순서 목록
*/
val siteOrder: Flow<List<SiteType>> = context.dataStore.data
.map { preferences ->
val savedStr = preferences[SITE_ORDER_KEY]
if (savedStr.isNullOrBlank()) {
SiteType.entries
} else {
val savedNames = savedStr.split(",")
val ordered = mutableListOf<SiteType>()
savedNames.forEach { name ->
try {
ordered.add(SiteType.valueOf(name.trim()))
} catch (e: Exception) {
// ignore invalid
}
}
// 누락된 신규 사이트가 있으면 끝에 추가
SiteType.entries.forEach { site ->
if (site !in ordered) {
ordered.add(site)
}
}
ordered
}
}
/**
* 사이트 표시 순서 저장
*/
suspend fun setSiteOrder(order: List<SiteType>) {
val str = order.joinToString(",") { it.name }
context.dataStore.edit { preferences ->
preferences[SITE_ORDER_KEY] = str
}
}
/**
* 키워드 ID 표시 순서 목록
*/
val keywordOrder: Flow<List<Long>> = context.dataStore.data
.map { preferences ->
val savedStr = preferences[KEYWORD_ORDER_KEY]
if (savedStr.isNullOrBlank()) {
emptyList()
} else {
savedStr.split(",").mapNotNull { it.trim().toLongOrNull() }
}
}
/**
* 키워드 ID 표시 순서 저장
*/
suspend fun setKeywordOrder(order: List<Long>) {
val str = order.joinToString(",") { it.toString() }
context.dataStore.edit { preferences ->
preferences[KEYWORD_ORDER_KEY] = str
}
}
}
@@ -0,0 +1,168 @@
package com.hotdeal.alarm.data.remote.scraper
import android.util.Log
import com.hotdeal.alarm.domain.model.HotDeal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.select.Elements
import kotlin.random.Random
/**
* 아카라이브 핫딜 채널 스크래퍼
*/
class ArcaLiveScraper(client: OkHttpClient) : BaseScraper(client) {
override val siteName: String = "arcalive"
override val baseUrl: String = "https://arca.live"
override fun getBoardUrl(board: String): String {
return "$baseUrl/b/$board"
}
override suspend fun scrape(board: String): Result<List<HotDeal>> = withContext(Dispatchers.IO) {
try {
val url = getBoardUrl(board)
Log.d(TAG, "스크래핑 시작: $url")
// 요청 간격 랜덤화 (2~4초)
val delayTime = Random.nextLong(2000, 4000)
Log.d(TAG, "요청 대기: ${delayTime}ms")
delay(delayTime)
val userAgent = getRandomUserAgent()
val doc: Document = Jsoup.connect(url)
.userAgent(userAgent)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Referer", "https://arca.live/")
.timeout(30000)
.followRedirects(true)
.get()
Log.d(TAG, "문서 파싱 성공, 길이: ${doc.html().length}")
val deals = mutableListOf<HotDeal>()
val seenPostIds = mutableSetOf<String>()
// 아카라이브 게시글 행 선택 (.vrow 중 공지 및 헤더 제외)
val elements: Elements = doc.select(".list-table .vrow, .article-list .vrow, div.vrow, a.vrow")
Log.d(TAG, "찾은 행 요소: ${elements.size}개")
var count = 0
for (row in elements) {
if (count >= 20) break
try {
// 공지사항 및 헤더 제외
if (row.hasClass("notice") ||
row.hasClass("notice-service") ||
row.hasClass("notice-board") ||
row.hasClass("head")
) {
continue
}
val titleElement = row.selectFirst("a.title") ?: continue
val href = titleElement.attr("href")
val postId = extractPostId(href)
if (postId.isEmpty() || !seenPostIds.add(postId)) {
continue
}
val dealUrl = resolveUrl(baseUrl, href)
// 쇼핑몰/스토어 정보, 카테고리 뱃지, 가격, 배송비 추출
val store = row.selectFirst(".deal-store")?.text()?.trim().orEmpty()
val badge = row.selectFirst(".badge")?.text()?.trim().orEmpty()
val price = row.selectFirst(".deal-price")?.text()
?.replace("\\s+".toRegex(), " ")?.trim().orEmpty()
// 제목 텍스트 정리 (댓글 수, 미디어 아이콘 등 제거)
val rawTitle = getCleanTitle(titleElement)
if (rawTitle.isEmpty()) continue
// 핫딜 조합 제목 생성 ([스토어] 제목 (가격))
val prefix = if (store.isNotEmpty()) "[$store]" else if (badge.isNotEmpty()) "[$badge]" else ""
val suffix = if (price.isNotEmpty()) "($price)" else ""
val fullTitle = buildString {
if (prefix.isNotEmpty() && !rawTitle.startsWith("[")) {
append(prefix).append(" ")
}
append(rawTitle)
if (suffix.isNotEmpty() && !rawTitle.contains(price)) {
append(" ").append(suffix)
}
}
// 추천수 파싱 (10개 이상이면 인기 핫딜)
val rateText = row.selectFirst(".col-rate")?.text()?.trim().orEmpty()
val rate = rateText.toIntOrNull() ?: 0
val isPopular = rate >= 10
val deal = HotDeal(
id = HotDeal.generateId(siteName, postId),
siteName = siteName,
boardName = board,
title = fullTitle,
url = dealUrl,
createdAt = System.currentTimeMillis(),
isPopular = isPopular
)
deals.add(deal)
count++
val popularMark = if (isPopular) " [인기]" else ""
Log.d(TAG, "[$count]$popularMark $fullTitle")
} catch (e: Exception) {
Log.e(TAG, "파싱 에러: ${e.message}")
}
}
Log.d(TAG, "파싱 완료: ${deals.size}개")
Result.success(deals)
} catch (e: Exception) {
Log.e(TAG, "스크래핑 실패: ${e.message}", e)
Result.failure(e)
}
}
private fun getCleanTitle(titleElement: Element): String {
val cloned = titleElement.clone()
cloned.select(".info, .media-icon, .comment-count").remove()
return cloned.text().trim()
}
private fun extractPostId(href: String): String {
val fromBoard = href.substringAfter("/b/hotdeal/", "")
val postId = fromBoard.takeWhile { it.isDigit() }
if (postId.isNotEmpty()) return postId
val fromPath = href.substringAfterLast("/").substringBefore("?")
if (fromPath.isNotEmpty() && fromPath.all { it.isDigit() }) {
return fromPath
}
return ""
}
private fun getRandomUserAgent(): String {
val userAgents = listOf(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:120.0) Gecko/20100101 Firefox/120.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0"
)
return userAgents.random()
}
companion object {
private const val TAG = "ArcaLive"
}
}
@@ -3,6 +3,7 @@ package com.hotdeal.alarm.data.remote.scraper
import android.util.Log
import com.hotdeal.alarm.domain.model.HotDeal
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@@ -13,6 +14,8 @@ import kotlin.random.Random
/**
* 뽐뿌 스크래퍼
* - hotlist_flag=999 페이지와 일반 게시판을 병렬 스크래핑하여 HOT 뱃지 신속 감지
* - 기존 hotpop_bg_color CSS 클래스도 폴백으로 감지
*/
class PpomppuScraper(client: OkHttpClient) : BaseScraper(client) {
@@ -28,69 +31,57 @@ class PpomppuScraper(client: OkHttpClient) : BaseScraper(client) {
val url = getBoardUrl(board)
Log.d("Ppomppu", "스크래핑 시작: $url")
// 요청 간격 랜덤화 (2~4초) - 차단 방지
val delayTime = Random.nextLong(2000, 4000)
Log.d("Ppomppu", "요청 대기: ${delayTime}ms")
// 1. 인기글 목록과 일반 게시판을 병렬(async)로 동시에 요청하여 딜레이 최소화
val hotPostIdsDeferred = async { fetchHotPostIds(board) }
val delayTime = Random.nextLong(500, 1500)
delay(delayTime)
// Jsoup으로 직접 연결 (User-Agent 회전)
val userAgent = getRandomUserAgent()
Log.d("Ppomppu", "User-Agent: $userAgent")
val doc: Document = Jsoup.connect(url)
.userAgent(userAgent)
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Accept-Charset", "utf-8,ISO-8859-1")
.header("Referer", "https://www.ppomppu.co.kr/")
.timeout(30000)
.timeout(12000)
.followRedirects(true)
.get()
Log.d("Ppomppu", "문서 파싱 성공, 길이: ${doc.html().length}")
val hotPostIds = hotPostIdsDeferred.await()
Log.d("Ppomppu", "인기글 ID ${hotPostIds.size}개 수집됨: $hotPostIds")
val deals = mutableListOf<HotDeal>()
// 셀렉터로 요소 찾기 - 인기 게시물 감지를 위해 tr 요소 선택
val rowElements: Elements = doc.select("tr.baseList")
Log.d("Ppomppu", "찾은 행 요소: ${rowElements.size}개")
// 최대 20개까지만 처리
var count = 0
rowElements.forEach { row ->
if (count >= 20) return@forEach
if (count >= 25) return@forEach
try {
// 인기 게시물 여부 확인 (hotpop_bg_color 클래스 존재 여부)
val isPopular = row.hasClass("hotpop_bg_color")
// 제목 링크 찾기
val titleElement = row.selectFirst("a.baseList-title")
if (titleElement == null) return@forEach
val titleElement = row.selectFirst("a.baseList-title") ?: return@forEach
val title = titleElement.text().trim()
if (title.isEmpty()) return@forEach
val href = titleElement.attr("href")
// 공지사항 제외
if (href.contains("regulation") || href.contains("notice")) return@forEach
val dealUrl = resolveUrl(baseUrl, href)
// postId 추출
val postId = extractPostId(href)
if (postId.isEmpty()) {
Log.w("Ppomppu", "postId 추출 실패: href=$href")
return@forEach
}
// 인기글 판단: hotlist 페이지에 포함되어 있거나 CSS 클래스로 감지
val isPopular = postId in hotPostIds || row.hasClass("hotpop_bg_color")
val deal = HotDeal(
id = HotDeal.generateId(siteName, postId),
siteName = siteName,
boardName = board,
title = title,
url = dealUrl,
url = resolveUrl(baseUrl, href),
createdAt = System.currentTimeMillis(),
isPopular = isPopular
)
@@ -103,7 +94,7 @@ class PpomppuScraper(client: OkHttpClient) : BaseScraper(client) {
}
}
Log.d("Ppomppu", "파싱 완료: ${deals.size}개")
Log.d("Ppomppu", "파싱 완료: ${deals.size}개 (인기: ${deals.count { it.isPopular }}개)")
Result.success(deals)
} catch (e: Exception) {
Log.e("Ppomppu", "스크래핑 실패: ${e.message}", e)
@@ -111,6 +102,38 @@ class PpomppuScraper(client: OkHttpClient) : BaseScraper(client) {
}
}
/**
* hotlist_flag=999 페이지에서 인기 게시물의 postId 목록을 수집
*/
private suspend fun fetchHotPostIds(board: String): Set<String> {
return try {
val hotUrl = "${baseUrl}zboard.php?id=$board&hotlist_flag=999"
Log.d("Ppomppu", "인기글 페이지 조회: $hotUrl")
val doc = Jsoup.connect(hotUrl)
.userAgent(getRandomUserAgent())
.header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
.header("Accept-Language", "ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7")
.header("Referer", "https://www.ppomppu.co.kr/")
.timeout(8000)
.followRedirects(true)
.get()
val ids = mutableSetOf<String>()
doc.select("tr.baseList a.baseList-title").forEach { link ->
val href = link.attr("href")
val postId = extractPostId(href)
if (postId.isNotEmpty()) {
ids.add(postId)
}
}
ids
} catch (e: Exception) {
Log.w("Ppomppu", "인기글 목록 조회 실패 (폴백으로 계속): ${e.message}")
emptySet()
}
}
private fun extractPostId(href: String): String {
val afterNo = href.substringAfter("no=", "")
if (afterNo.isEmpty()) return ""
@@ -9,7 +9,8 @@ class ScraperFactory(
private val ppomppu: PpomppuScraper,
private val clien: ClienScraper,
private val ruriweb: RuriwebScraper,
private val coolenjoy: CoolenjoyScraper
private val coolenjoy: CoolenjoyScraper,
private val arcaLive: ArcaLiveScraper
) {
/**
* 사이트 타입에 따른 스크래퍼 반환
@@ -20,6 +21,7 @@ class ScraperFactory(
SiteType.CLIEN -> clien
SiteType.RURIWEB -> ruriweb
SiteType.COOLENJOY -> coolenjoy
SiteType.ARCALIVE -> arcaLive
}
}
@@ -32,6 +34,7 @@ class ScraperFactory(
"clien" -> clien
"ruriweb" -> ruriweb
"coolenjoy" -> coolenjoy
"arcalive" -> arcaLive
else -> null
}
}
@@ -40,6 +43,6 @@ class ScraperFactory(
* 모든 스크래퍼 반환
*/
fun getAllScrapers(): List<BaseScraper> {
return listOf(ppomppu, clien, ruriweb, coolenjoy)
return listOf(ppomppu, clien, ruriweb, coolenjoy, arcaLive)
}
}
@@ -62,19 +62,27 @@ object NetworkModule {
return CoolenjoyScraper(client)
}
@Provides
@Singleton
fun provideArcaLiveScraper(client: OkHttpClient): ArcaLiveScraper {
return ArcaLiveScraper(client)
}
@Provides
@Singleton
fun provideScraperFactory(
ppomppu: PpomppuScraper,
clien: ClienScraper,
ruriweb: RuriwebScraper,
coolenjoy: CoolenjoyScraper
coolenjoy: CoolenjoyScraper,
arcaLive: ArcaLiveScraper
): ScraperFactory {
return ScraperFactory(
ppomppu = ppomppu,
clien = clien,
ruriweb = ruriweb,
coolenjoy = coolenjoy
coolenjoy = coolenjoy,
arcaLive = arcaLive
)
}
}
@@ -35,6 +35,12 @@ enum class SiteType(
boards = listOf(
BoardInfo("jirum", "알뜰구매")
)
),
ARCALIVE(
displayName = "아카라이브",
boards = listOf(
BoardInfo("hotdeal", "핫딜")
)
);
companion object {
@@ -0,0 +1,175 @@
package com.hotdeal.alarm.domain.usecase
import android.content.Context
import android.util.Log
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.HotDealEntity
import com.hotdeal.alarm.data.local.db.entity.KeywordEntity
import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity
import com.hotdeal.alarm.data.remote.scraper.ScraperFactory
import com.hotdeal.alarm.domain.model.HotDeal
import com.hotdeal.alarm.service.NotificationService
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* 핫딜 스크래핑, DB 저장, 키워드 매칭, 알림 발송을 총괄하는 핵심 매니저
*
* 포그라운드(앱 진입/새로고침), 백그라운드(AlarmReceiver), WorkManager 모두에서
* 공통으로 직접 호출하여 지연 없이 즉시 수집 및 알림을 발송합니다.
*/
@Singleton
class HotDealScraperManager @Inject constructor(
@ApplicationContext private val context: Context,
private val scraperFactory: ScraperFactory,
private val notificationService: NotificationService,
private val dealDao: HotDealDao,
private val siteConfigDao: SiteConfigDao,
private val keywordDao: KeywordDao
) {
companion object {
private const val TAG = "HotDealScraperManager"
}
// 중복 동시 실행 방지 뮤텍스
private val mutex = Mutex()
/**
* 핫딜 수집 및 키워드 알림 발송 전체 프로세스 실행
* @return 수집된 새로운 핫딜 목록
*/
suspend fun fetchAndProcessDeals(): List<HotDeal> = withContext(Dispatchers.IO) {
mutex.withLock {
try {
Log.d(TAG, "===== 🚀 핫딜 수집 프로세스 시작 =====")
// 1. 활성화된 사이트 설정 조회
val enabledConfigs = siteConfigDao.getEnabledConfigs()
Log.d(TAG, "활성화된 사이트 설정: ${enabledConfigs.size}개")
if (enabledConfigs.isEmpty()) {
Log.w(TAG, "활성화된 사이트 설정이 없습니다.")
return@withContext emptyList()
}
// 2. 활성화된 키워드 조회
val keywords = keywordDao.getEnabledKeywords()
Log.d(TAG, "활성화된 키워드: ${keywords.size}개 (${keywords.map { it.keyword }})")
// 3. 각 사이트/게시판 병렬 스크래핑
val allDeals = enabledConfigs.map { config ->
async { scrapeSite(config) }
}.awaitAll().flatten()
Log.d(TAG, "스크래핑 완료: 총 ${allDeals.size}개 핫딜 수집됨")
if (allDeals.isEmpty()) {
return@withContext emptyList()
}
// 4. 새로운 핫딜 필터링 (기존 DB에 없는 것)
val newDeals = filterNewDeals(allDeals)
Log.d(TAG, "새로운 핫딜: ${newDeals.size}개")
// 5. 인기글 상태 일괄 업데이트 (기존 + 신규 모두)
val popularDealIds = allDeals.filter { it.isPopular }.map { it.id }
if (popularDealIds.isNotEmpty()) {
dealDao.markAsPopular(popularDealIds)
Log.d(TAG, "🔥 인기글 마킹: ${popularDealIds.size}개")
}
if (newDeals.isNotEmpty()) {
// 6. 키워드 매칭
val keywordMatchedDeals = if (keywords.isNotEmpty()) {
filterByKeywords(newDeals, keywords).also {
Log.d(TAG, "⭐ 키워드 매칭 핫딜: ${it.size}개 -> ${it.map { d -> d.title }}")
}
} else {
emptyList()
}
// 7. DB에 새 핫딜 저장
var insertedCount = 0
newDeals.forEach { deal ->
try {
val entity = HotDealEntity.fromDomain(deal)
dealDao.insertDeal(entity)
insertedCount++
} catch (e: Exception) {
Log.w(TAG, "핫딜 저장 실패: ${deal.id}")
}
}
Log.d(TAG, "DB 저장 완료: $insertedCount 개")
// 8. 키워드 매칭 알림 즉시 발송! (시스템바 노티피케이션)
if (keywordMatchedDeals.isNotEmpty()) {
Log.d(TAG, "🔔 키워드 매칭 알림 발송: ${keywordMatchedDeals.size}개")
notificationService.showKeywordMatchNotification(keywordMatchedDeals)
dealDao.markAsKeywordMatch(keywordMatchedDeals.map { it.id })
}
// 9. 알림 발송 상태 업데이트
dealDao.markAsNotified(newDeals.map { it.id })
}
// 10. 오래된 데이터 정리 (3일 이상)
val threshold = System.currentTimeMillis() - (3 * 24 * 60 * 60 * 1000L)
dealDao.deleteOldDeals(threshold)
Log.d(TAG, "===== ✅ 핫딜 수집 프로세스 종료 =====")
newDeals
} catch (e: Exception) {
Log.e(TAG, "핫딜 수집 오류: ${e.message}", e)
emptyList()
}
}
}
private suspend fun scrapeSite(config: SiteConfigEntity): List<HotDeal> {
val scraper = scraperFactory.getScraper(config.siteName) ?: return emptyList()
val result = scraper.scrape(config.boardName)
result.fold(
onSuccess = { deals ->
try {
siteConfigDao.updateLastScrapedAt(config.siteBoardKey, System.currentTimeMillis())
} catch (e: Exception) {
// 무시
}
},
onFailure = { error ->
Log.e(TAG, "${config.siteName}/${config.boardName} 실패: ${error.message}")
}
)
return result.getOrElse { emptyList() }
}
private suspend fun filterNewDeals(deals: List<HotDeal>): List<HotDeal> {
return deals.filter { deal ->
val existingByUrl = dealDao.getDealByUrl(deal.url)
if (existingByUrl != null) return@filter false
val existingById = dealDao.getDealById(deal.id)
if (existingById != null) return@filter false
true
}
}
private fun filterByKeywords(deals: List<HotDeal>, keywords: List<KeywordEntity>): List<HotDeal> {
return deals.filter { deal ->
keywords.any { keyword ->
deal.title.contains(keyword.keyword, ignoreCase = true)
}
}
}
}
@@ -2,137 +2,160 @@ package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.hotdeal.alarm.ui.theme.CornerRadius
import com.hotdeal.alarm.ui.theme.Spacing
/**
* 애니메이션이 있는 아이콘 버튼
* One UI 9 Elastic Spring Press Effect
* 클릭 시 부드럽게 눌리고 뗐을 때 탄성 있게 복원되는 고품질 인터랙션
*/
@Composable
fun AnimatedIconButton(
onClick: () -> Unit,
icon: @Composable () -> Unit,
modifier: Modifier = Modifier,
isActivated: Boolean = false
) {
val scale by animateFloatAsState(
targetValue = if (isActivated) 1.2f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "icon_scale"
)
IconButton(
onClick = onClick,
modifier = modifier.scale(scale)
) {
icon()
}
}
/**
* 페이드 인 애니메이션 박스
*/
@Composable
fun FadeInBox(
modifier: Modifier = Modifier,
delayMillis: Int = 0,
content: @Composable AnimatedVisibilityScope.() -> Unit
) {
var visible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
kotlinx.coroutines.delay(delayMillis.toLong())
visible = true
}
AnimatedVisibility(
visible = visible,
enter = fadeIn(
animationSpec = tween(300)
) + slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 4 }
),
modifier = modifier
) {
content()
}
}
/**
* 스케일 애니메이션 카드
*/
@Composable
fun ScaleAnimationCard(
modifier: Modifier = Modifier,
onClick: () -> Unit,
content: @Composable () -> Unit
) {
var isPressed by remember { mutableStateOf(false) }
fun Modifier.elasticPressClickable(
interactionSource: MutableInteractionSource? = null,
enabled: Boolean = true,
pressedScale: Float = 0.97f,
onClick: () -> Unit
): Modifier = composed {
val source = interactionSource ?: remember { MutableInteractionSource() }
val isPressed by source.collectIsPressedAsState()
val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f,
targetValue = if (isPressed && enabled) pressedScale else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh
stiffness = Spring.StiffnessMedium
),
label = "card_scale"
label = "elastic_press_scale"
)
Card(
modifier = modifier
.scale(scale),
onClick = {
isPressed = true
onClick()
}
) {
content()
}
LaunchedEffect(isPressed) {
if (isPressed) {
kotlinx.coroutines.delay(100)
isPressed = false
}
}
this
.scale(scale)
.clickable(
interactionSource = source,
indication = null, // 불필요한 기본 리플 번짐을 끄고 Elastic Scale로 최고급 감각 제공
enabled = enabled,
onClick = onClick
)
}
/**
* 로딩 오버레이
* One UI 9 Squircle Container Card with Ambient Border
*/
@Composable
fun LoadingOverlay(
isLoading: Boolean,
fun SquircleCard(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Box(modifier = modifier) {
content()
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize()
shape: Shape = CornerRadius.shapeSquircle,
containerColor: Color = MaterialTheme.colorScheme.surface,
borderColor: Color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f),
borderWidth: Dp = 0.8.dp,
content: @Composable BoxScope.() -> Unit
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(Spacing.lg),
contentAlignment = Alignment.Center
modifier = modifier
.clip(shape)
.background(containerColor)
.border(borderWidth, borderColor, shape),
content = content
)
}
/**
* One UI 9 Pill Capsule Badge
*/
@Composable
fun PillBadge(
text: String,
modifier: Modifier = Modifier,
backgroundColor: Color = MaterialTheme.colorScheme.primaryContainer,
textColor: Color = MaterialTheme.colorScheme.onPrimaryContainer,
icon: (@Composable () -> Unit)? = null
) {
CircularProgressIndicator()
Surface(
shape = CornerRadius.shapePill,
color = backgroundColor,
modifier = modifier.height(24.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 8.dp, vertical = 2.dp)
) {
icon?.invoke()
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = textColor
)
}
}
}
/**
* One UI 9 Now Status Pill (상단 브리핑 캡슐)
*/
@Composable
fun NowStatusPill(
text: String,
modifier: Modifier = Modifier,
isLive: Boolean = true,
accentColor: Color = MaterialTheme.colorScheme.primary
) {
val infiniteTransition = rememberInfiniteTransition(label = "now_pulse")
val dotAlpha by infiniteTransition.animateFloat(
initialValue = 0.4f,
targetValue = 1.0f,
animationSpec = infiniteRepeatable(
animation = tween(1000, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "dot_alpha"
)
Surface(
shape = CornerRadius.shapePill,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
border = androidx.compose.foundation.BorderStroke(
0.8.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
),
modifier = modifier.height(28.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 10.dp)
) {
if (isLive) {
Box(
modifier = Modifier
.size(7.dp)
.background(accentColor.copy(alpha = dotAlpha), CircleShape)
)
}
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@@ -2,11 +2,8 @@ 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
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
@@ -14,22 +11,21 @@ import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
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 androidx.compose.ui.unit.sp
import com.hotdeal.alarm.domain.model.HotDeal
import com.hotdeal.alarm.ui.theme.getSiteColor
import com.hotdeal.alarm.ui.theme.*
import com.hotdeal.alarm.util.ShareHelper
@OptIn(ExperimentalMaterial3Api::class)
/**
* One UI 9 & Material 3 Expressive Compact Deal Item Card
* 화면 공간 낭비를 없애고 가독성과 정보 밀도를 극대화한 슬림 핫딜 카드
*/
@Composable
fun DealItem(
deal: HotDeal,
@@ -38,254 +34,239 @@ fun DealItem(
modifier: Modifier = Modifier
) {
val context = LocalContext.current
// 부드러운 바운스 애니메이션
val favoriteScale by animateFloatAsState(
targetValue = if (deal.isFavorite) 1.15f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessLow
),
label = "favorite_scale"
)
val siteColor = getSiteColor(deal.siteType)
// 키워드 매칭 배경 - 옅은 붉은색 단색 배경
val cardColors = if (deal.isKeywordMatch) {
CardDefaults.elevatedCardColors(
containerColor = Color(0xFFFCE8E8) // 옅은 붉은색
)
} else {
CardDefaults.elevatedCardColors(
containerColor = MaterialTheme.colorScheme.surface
val favoriteScale by animateFloatAsState(
targetValue = if (deal.isFavorite) 1.2f else 1f,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium
),
label = "fav_scale"
)
val parsedInfo = remember(deal.title) {
parseDealTitle(deal.title)
}
Box(modifier = modifier) {
// 키워드 매칭 게시물도 일반 카드와 동일한 형태로 표시 (색상만 다름)
val (cardBorderColor, cardBorderWidth) = when {
deal.isKeywordMatch -> KeywordGold to 1.2.dp
deal.isPopular -> HotDealFlameColor.copy(alpha = 0.75f) to 1.dp
else -> MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f) to 0.8.dp
}
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = cardColors,
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = 2.dp
),
onClick = onClick
val cardBgColor = when {
deal.isKeywordMatch -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f)
else -> MaterialTheme.colorScheme.surface
}
SquircleCard(
shape = CornerRadius.shapeNormal,
containerColor = cardBgColor,
borderColor = cardBorderColor,
borderWidth = cardBorderWidth,
modifier = modifier
.fillMaxWidth()
.elasticPressClickable(onClick = onClick)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.padding(horizontal = 14.dp, vertical = 9.dp)
) {
// 상단: 사이트 뱃지 + 게시판 + 액션
// ============================================
// 1. 상단 메타 바: 사이트 뱃지 + 게시판 + 쇼핑몰 + 특수 뱃지
// ============================================
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 사이트 뱃지 - 개선된 디자인
// 사이트 캡슐 뱃지
Surface(
shape = RoundedCornerShape(12.dp),
shape = CornerRadius.shapeMicro,
color = siteColor.copy(alpha = 0.12f),
modifier = Modifier.height(28.dp)
modifier = Modifier.height(21.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(horizontal = 10.dp)
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 6.dp)
) {
Box(
modifier = Modifier
.size(8.dp)
.size(5.dp)
.background(siteColor, CircleShape)
)
Text(
text = deal.siteType?.displayName ?: deal.siteName,
style = MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
),
style = SpotlightTypography.badge.copy(fontSize = 11.sp),
color = siteColor
)
}
}
Spacer(modifier = Modifier.width(8.dp))
Spacer(modifier = Modifier.width(5.dp))
// 게시판 이름 - 더 세련된 스타일
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.height(24.dp)
) {
// 게시판 라벨
Text(
text = deal.boardDisplayName,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.sp,
fontWeight = FontWeight.Medium
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp)
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
// 쇼핑몰 태그
if (parsedInfo.storeTag.isNotEmpty()) {
Spacer(modifier = Modifier.width(5.dp))
Surface(
shape = CornerRadius.shapeMicro,
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.6f),
modifier = Modifier.height(19.dp)
) {
Text(
text = parsedInfo.storeTag,
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp),
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 5.dp, vertical = 1.dp)
)
}
}
Spacer(modifier = Modifier.weight(1f))
// 키워드 매칭 배지 - 더 눈에 띄는 디자인
// 키워드 매칭 뱃지
if (deal.isKeywordMatch) {
Surface(
shape = RoundedCornerShape(10.dp),
color = Color(0xFFE53935),
modifier = Modifier.height(24.dp)
shape = CornerRadius.shapeMicro,
color = KeywordGold,
modifier = Modifier.height(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp)
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(horizontal = 6.dp)
) {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(12.dp)
modifier = Modifier.size(10.dp)
)
Text(
text = "내 키워드",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
),
style = SpotlightTypography.badge.copy(fontSize = 10.5.sp),
color = Color.White
)
}
}
Spacer(modifier = Modifier.width(4.dp))
Spacer(modifier = Modifier.width(3.dp))
}
// 인기/핫 배지 - 인기 게시물
// 인기 핫딜 뱃지
if (deal.isPopular) {
Surface(
shape = RoundedCornerShape(10.dp),
color = Color(0xFFFF6B35), // 오렌지색
modifier = Modifier.height(24.dp)
shape = CornerRadius.shapeMicro,
color = HotDealFlameColor,
modifier = Modifier.height(20.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(horizontal = 10.dp)
horizontalArrangement = Arrangement.spacedBy(2.dp),
modifier = Modifier.padding(horizontal = 6.dp)
) {
Icon(
imageVector = Icons.Filled.Whatshot,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(12.dp)
modifier = Modifier.size(10.dp)
)
Text(
text = "인기",
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
),
text = "HOT",
style = SpotlightTypography.badge.copy(fontSize = 10.5.sp),
color = Color.White
)
}
}
}
}
// 액션 버튼들 - 더 작고 세련된 스타일
Spacer(modifier = Modifier.height(6.dp))
// ============================================
// 2. 본문 제목 (Slim 2-Line Spotlight)
// ============================================
Text(
text = parsedInfo.cleanTitle,
style = MaterialTheme.typography.titleSmall.copy(
fontSize = 14.5.sp,
fontWeight = if (deal.isKeywordMatch) FontWeight.Bold else FontWeight.SemiBold,
lineHeight = 19.sp
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(6.dp))
// ============================================
// 3. 하단 바: 가격(Spotlight) + 시간 + 액션 아이콘
// ============================================
Row(
horizontalArrangement = Arrangement.spacedBy(0.dp)
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
if (parsedInfo.priceText.isNotEmpty()) {
Text(
text = parsedInfo.priceText,
style = SpotlightTypography.priceLarge.copy(fontSize = 16.sp),
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "•",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
)
Spacer(modifier = Modifier.width(6.dp))
}
Text(
text = formatRelativeTime(deal.createdAt),
style = MaterialTheme.typography.labelSmall.copy(fontSize = 11.sp),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f)
)
Spacer(modifier = Modifier.weight(1f))
Row(
horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 공유 버튼
IconButton(
onClick = { ShareHelper.shareDeal(context, deal) },
modifier = Modifier.size(36.dp)
modifier = Modifier.size(28.dp)
) {
Icon(
imageVector = Icons.Outlined.Share,
contentDescription = "공유",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp)
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.size(15.dp)
)
}
// 즐겨찾기 버튼
IconButton(
onClick = { onFavoriteToggle(deal.id) },
modifier = Modifier
.size(36.dp)
.size(28.dp)
.scale(favoriteScale)
) {
Icon(
imageVector = if (deal.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (deal.isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가",
tint = if (deal.isFavorite)
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp)
)
}
}
}
Spacer(modifier = Modifier.height(12.dp))
// 제목 - 더 크고 읽기 쉬운 스타일
Text(
text = deal.title,
style = if (deal.isKeywordMatch) {
MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Bold,
fontSize = 16.sp,
letterSpacing = (-0.1).sp
)
} else {
MaterialTheme.typography.bodyLarge.copy(
fontWeight = FontWeight.Normal,
fontSize = 15.sp
)
},
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface,
lineHeight = 22.sp
)
Spacer(modifier = Modifier.height(10.dp))
// 하단: 시간 + 화살표
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 시간 - 아이콘 없이 깔끔하게
Text(
text = formatTime(deal.createdAt),
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = FontWeight.Medium,
fontSize = 12.sp
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(modifier = Modifier.weight(1f))
// 화살표 (클릭 유도) - 더 세련된 스타일
Surface(
shape = CircleShape,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f),
modifier = Modifier.size(28.dp)
) {
Box(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.ArrowForward,
contentDescription = "이동",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp)
tint = if (deal.isFavorite) FavoriteColor else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.size(16.dp)
)
}
}
@@ -293,9 +274,49 @@ fun DealItem(
}
}
}
/**
* 핫딜 제목에서 상점 태그 및 가격을 분리하는 헬퍼
*/
private data class ParsedDealInfo(
val storeTag: String,
val priceText: String,
val cleanTitle: String
)
private fun parseDealTitle(rawTitle: String): ParsedDealInfo {
var title = rawTitle.trim()
var store = ""
var price = ""
// 상점 태그 추출: [스토어]
if (title.startsWith("[")) {
val closeIdx = title.indexOf("]")
if (closeIdx in 1..25) {
store = title.substring(1, closeIdx).trim()
title = title.substring(closeIdx + 1).trim()
}
}
private fun formatTime(timestamp: Long): String {
// 끝부분 가격 추출: (10,000원), (35,000/무료) 등
val pricePattern = Regex("""\(([^()]*?(?:원|₩|\$|무료|배송|KRW)[^()]*?)\)$""")
val match = pricePattern.find(title)
if (match != null) {
price = match.groupValues[1].trim()
title = title.substring(0, match.range.first).trim()
}
return ParsedDealInfo(
storeTag = store,
priceText = price,
cleanTitle = if (title.isEmpty()) rawTitle else title
)
}
/**
* 상대 시간 포맷팅
*/
private fun formatRelativeTime(timestamp: Long): String {
val now = System.currentTimeMillis()
val diff = now - timestamp
@@ -0,0 +1,117 @@
package com.hotdeal.alarm.presentation.components
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.zIndex
/**
* Key 기반 드래그 앤 드롭 Reorder 시스템
*
* - 각 아이템에 item-level long-press 제스처를 적용하여 드래그 시작
* - 드래그 중 다른 아이템의 중심점과 겹치면 swap 실행
* - swap 시 delta 보정으로 시각적 점프 방지
* - 비드래그 아이템은 animateItemPlacement()로 부드러운 슬라이드
*/
@Composable
fun rememberReorderState(
listState: LazyListState,
onSwap: (fromListIndex: Int, toListIndex: Int) -> Boolean
): ReorderState {
return remember(listState) {
ReorderState(listState, onSwap)
}
}
@Stable
class ReorderState(
val listState: LazyListState,
private val onSwap: (Int, Int) -> Boolean
) {
/** 현재 드래그 중인 아이템의 key (null이면 드래그 없음) */
var draggedKey by mutableStateOf<Any?>(null)
private set
/** 누적 드래그 Y축 오프셋 */
var dragDelta by mutableFloatStateOf(0f)
private set
fun startDrag(key: Any) {
draggedKey = key
dragDelta = 0f
}
fun updateDrag(delta: Offset) {
if (draggedKey == null) return
dragDelta += delta.y
checkOverlap()
}
fun endDrag() {
draggedKey = null
dragDelta = 0f
}
private fun checkOverlap() {
val dragged = listState.layoutInfo.visibleItemsInfo
.firstOrNull { it.key == draggedKey } ?: return
val draggedMid = dragged.offset + dragged.size / 2f + dragDelta
val target = listState.layoutInfo.visibleItemsInfo
.firstOrNull { item ->
item.key != draggedKey &&
draggedMid >= item.offset.toFloat() &&
draggedMid < (item.offset + item.size).toFloat()
} ?: return
if (dragged.index == target.index) return
val swapped = onSwap(dragged.index, target.index)
if (swapped) {
// swap 후 아이템이 새 위치로 이동하므로, 시각적 점프를 막기 위해 delta 보정
dragDelta += if (dragged.index < target.index) {
-target.size.toFloat()
} else {
target.size.toFloat()
}
}
}
}
/**
* 개별 아이템에 부착하는 드래그 핸들 + 시각 효과 Modifier
* - 길게 누르면 드래그 시작
* - 드래그 중 아이템이 떠오르며 그림자 및 스케일업
*/
fun Modifier.reorderableItem(
state: ReorderState,
key: Any
): Modifier {
val isDragged = state.draggedKey == key
return this
.zIndex(if (isDragged) 10f else 0f)
.graphicsLayer {
if (isDragged) {
translationY = state.dragDelta
scaleX = 1.03f
scaleY = 1.03f
shadowElevation = 16f
}
}
.pointerInput(key) {
detectDragGesturesAfterLongPress(
onDragStart = { state.startDrag(key) },
onDrag = { change, dragAmount ->
change.consume()
state.updateDrag(dragAmount)
},
onDragEnd = { state.endDrag() },
onDragCancel = { state.endDrag() }
)
}
}
@@ -3,7 +3,6 @@ package com.hotdeal.alarm.presentation.deallist
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
@@ -12,7 +11,8 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
@@ -22,22 +22,30 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex
import androidx.compose.ui.draw.scale
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hotdeal.alarm.domain.model.SiteType
import com.hotdeal.alarm.presentation.components.*
import com.hotdeal.alarm.presentation.main.MainUiState
import com.hotdeal.alarm.presentation.main.MainViewModel
import com.hotdeal.alarm.ui.theme.getSiteColor
import kotlinx.coroutines.delay
import com.hotdeal.alarm.ui.theme.*
import kotlinx.coroutines.launch
/**
* One UI 9 & Material 3 Expressive Compact Deal Feed Screen
* 온디맨드 검색(In-AppBar Search)과 스크롤 반응형 상단바로 화면 시야 및 컨텐츠 노출 수를 극대화한 화면
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DealListScreen(
@@ -45,102 +53,154 @@ fun DealListScreen(
onNavigateToSettings: () -> Unit = {}
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val siteOrder by viewModel.siteOrder.collectAsStateWithLifecycle()
val context = LocalContext.current
val focusManager = LocalFocusManager.current
// Pull to Refresh 상태 - 민감도 대폭 향상
val pullToRefreshState = rememberPullToRefreshState(
positionalThreshold = 40.dp // 기본값의 1/3로 설정하여 매우 쉽게 새로고침
)
var isRefreshing by remember { mutableStateOf(false) }
// 스크롤 시 상단바 자동 축소/숨김 동작
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
// Pull to Refresh
val pullToRefreshState = rememberPullToRefreshState(positionalThreshold = 40.dp)
val isRefreshingByVm by viewModel.isRefreshing.collectAsStateWithLifecycle()
// List state for scroll detection
val listState = rememberLazyListState()
val scope = rememberCoroutineScope()
val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 3 }
}
// 새로고침 완료 감지
LaunchedEffect(uiState) {
if (uiState !is MainUiState.Loading) {
isRefreshing = false
LaunchedEffect(isRefreshingByVm) {
if (!isRefreshingByVm) {
pullToRefreshState.endRefresh()
}
}
// 검색 모드 및 필터 상태
var isSearchActive by remember { mutableStateOf(false) }
var searchText by remember { mutableStateOf("") }
val searchFocusRequester = remember { FocusRequester() }
var selectedSiteFilter by remember { mutableStateOf<SiteType?>(null) }
var showFavoritesOnly by remember { mutableStateOf(false) }
var showPopularOnly by remember { mutableStateOf(false) }
var showKeywordMatchOnly by remember { mutableStateOf(false) }
var showFilterMenu by remember { mutableStateOf(false) }
// 필터 메뉴 자동 닫기 (5초)
LaunchedEffect(showFilterMenu) {
if (showFilterMenu) {
delay(5000L)
showFilterMenu = false
LaunchedEffect(isSearchActive) {
if (isSearchActive) {
searchFocusRequester.requestFocus()
}
}
Scaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
topBar = {
TopAppBar(
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
if (isSearchActive) {
// 검색 모드일 때의 인라인 검색 텍스트 필드
TextField(
value = searchText,
onValueChange = { searchText = it },
placeholder = {
Text(
text = "키워드, 브랜드, 상품명 검색...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { focusManager.clearFocus() }),
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
),
textStyle = MaterialTheme.typography.bodyLarge,
modifier = Modifier
.fillMaxWidth()
.focusRequester(searchFocusRequester)
)
} else {
// 기본 상단 타이틀
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(36.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape),
.size(32.dp)
.clip(CornerRadius.shapeMicro)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Filled.Notifications,
imageVector = Icons.Filled.LocalFireDepartment,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(18.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
Text(
text = "핫딜 알람",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
letterSpacing = (-0.2).sp
)
}
}
},
navigationIcon = {
if (isSearchActive) {
IconButton(onClick = {
isSearchActive = false
searchText = ""
}) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "검색 닫기",
tint = MaterialTheme.colorScheme.onSurface
)
}
}
},
actions = {
BadgedBox(
badge = {
if (selectedSiteFilter != null || showFavoritesOnly || showKeywordMatchOnly) {
Badge(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary
)
}
}
) {
IconButton(onClick = { showFilterMenu = !showFilterMenu }) {
if (isSearchActive) {
if (searchText.isNotEmpty()) {
IconButton(onClick = { searchText = "" }) {
Icon(
imageVector = if (showFilterMenu) Icons.Filled.FilterList else Icons.Outlined.FilterList,
contentDescription = "필터"
imageVector = Icons.Filled.Close,
contentDescription = "검색어 지우기",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
} else {
// 1. 검색 버튼 (누르면 검색 모드로 전환)
IconButton(onClick = { isSearchActive = true }) {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = "검색",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
// 2. 새로고침 버튼
IconButton(onClick = { viewModel.refresh() }) {
Icon(
imageVector = Icons.Outlined.Refresh,
contentDescription = "새로고침"
contentDescription = "새로고침",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
IconButton(onClick = { onNavigateToSettings() }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "설정"
)
}
},
scrollBehavior = scrollBehavior,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
containerColor = MaterialTheme.colorScheme.background,
scrolledContainerColor = MaterialTheme.colorScheme.surface
),
windowInsets = WindowInsets(0, 0, 0, 0)
@@ -159,11 +219,12 @@ fun DealListScreen(
containerColor = MaterialTheme.colorScheme.primary,
contentColor = MaterialTheme.colorScheme.onPrimary,
shape = CircleShape,
modifier = Modifier.navigationBarsPadding()
modifier = Modifier.size(44.dp).navigationBarsPadding()
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowUp,
contentDescription = "맨 위로"
contentDescription = "맨 위로",
modifier = Modifier.size(22.dp)
)
}
}
@@ -181,144 +242,109 @@ fun DealListScreen(
.padding(paddingValues)
.consumeWindowInsets(paddingValues)
) {
// 필터 메뉴
AnimatedVisibility(
visible = showFilterMenu,
enter = expandVertically(animationSpec = spring(stiffness = Spring.StiffnessLow)) + fadeIn(),
exit = shrinkVertically(animationSpec = spring(stiffness = Spring.StiffnessLow)) + fadeOut()
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Outlined.FilterAlt,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "사이트 필터",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(12.dp))
// ============================================
// 슬림 가로 스크롤 Sticky Pill Filter Bar
// ============================================
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically
) {
EnhancedFilterChip(
selected = selectedSiteFilter == null,
onClick = { selectedSiteFilter = null },
Spacer(modifier = Modifier.width(8.dp))
FilterPillChip(
selected = selectedSiteFilter == null && !showFavoritesOnly && !showPopularOnly && !showKeywordMatchOnly,
onClick = {
selectedSiteFilter = null
showFavoritesOnly = false
showPopularOnly = false
showKeywordMatchOnly = false
},
label = "전체",
color = MaterialTheme.colorScheme.primary
accentColor = MaterialTheme.colorScheme.primary
)
SiteType.entries.forEach { siteType ->
val color = getSiteColor(siteType)
EnhancedFilterChip(
FilterPillChip(
selected = showPopularOnly,
onClick = {
showPopularOnly = !showPopularOnly
if (showPopularOnly) {
showFavoritesOnly = false
showKeywordMatchOnly = false
}
},
label = "🔥 인기",
accentColor = HotDealFlameColor
)
FilterPillChip(
selected = showKeywordMatchOnly,
onClick = {
showKeywordMatchOnly = !showKeywordMatchOnly
if (showKeywordMatchOnly) {
showFavoritesOnly = false
showPopularOnly = false
}
},
label = "⭐ 내 키워드",
accentColor = KeywordGold
)
FilterPillChip(
selected = showFavoritesOnly,
onClick = {
showFavoritesOnly = !showFavoritesOnly
if (showFavoritesOnly) {
showPopularOnly = false
showKeywordMatchOnly = false
}
},
label = "❤️ 보관함",
accentColor = FavoriteColor
)
siteOrder.forEach { siteType ->
val siteColor = getSiteColor(siteType)
FilterPillChip(
selected = selectedSiteFilter == siteType,
onClick = {
selectedSiteFilter = if (selectedSiteFilter == siteType) null else siteType
},
label = siteType.displayName,
color = color
accentColor = siteColor
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// 특수 필터
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// 내 키워드 필터
EnhancedFilterChip(
selected = showKeywordMatchOnly,
onClick = { showKeywordMatchOnly = !showKeywordMatchOnly },
label = "내 키워드",
color = MaterialTheme.colorScheme.primary
)
// 즐겨찾기 필터
EnhancedFilterChip(
selected = showFavoritesOnly,
onClick = { showFavoritesOnly = !showFavoritesOnly },
label = "즐겨찾기",
color = MaterialTheme.colorScheme.error
)
}
}
}
Spacer(modifier = Modifier.width(8.dp))
}
// 검색창
OutlinedTextField(
value = searchText,
onValueChange = { searchText = it },
placeholder = {
Text(
text = "제목으로 검색...",
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
singleLine = true,
shape = RoundedCornerShape(16.dp),
leadingIcon = {
Icon(
imageVector = Icons.Outlined.Search,
contentDescription = "검색",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingIcon = {
if (searchText.isNotEmpty()) {
IconButton(onClick = { searchText = "" }) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "지우기",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)
)
)
// 딜 리스트
// ============================================
// 핫딜 목록 본문 (List Content)
// ============================================
when (val state = uiState) {
is MainUiState.Loading -> {
DealListSkeleton(count = 5)
DealListSkeleton(count = 6)
}
is MainUiState.Success -> {
val filteredDeals = remember(state.deals, searchText, selectedSiteFilter, showFavoritesOnly, showKeywordMatchOnly) {
val filteredDeals = remember(
state.deals,
searchText,
selectedSiteFilter,
showFavoritesOnly,
showPopularOnly,
showKeywordMatchOnly
) {
state.deals.filter { deal ->
val matchesSearch = searchText.isBlank() || deal.title.contains(searchText, ignoreCase = true)
val matchesSite = selectedSiteFilter == null || deal.siteType == selectedSiteFilter
val matchesFavorite = !showFavoritesOnly || deal.isFavorite
val matchesPopular = !showPopularOnly || deal.isPopular
val matchesKeyword = !showKeywordMatchOnly || deal.isKeywordMatch
matchesSearch && matchesSite && matchesFavorite && matchesKeyword
matchesSearch && matchesSite && matchesFavorite && matchesPopular && matchesKeyword
}
}
@@ -326,74 +352,31 @@ fun DealListScreen(
if (state.deals.isEmpty()) {
NoDealsState(onRefresh = { viewModel.refresh() })
} else {
val selectedFilter = selectedSiteFilter
val message = when {
showKeywordMatchOnly -> "키워드 매칭된 핫딜이 없습니다"
showPopularOnly -> "인기 핫딜이 없습니다"
showFavoritesOnly -> "즐겨찾기한 핫딜이 없습니다"
selectedFilter != null -> "${selectedFilter.displayName}의 핫딜이 없습니다"
selectedSiteFilter != null -> "${selectedSiteFilter?.displayName}의 핫딜이 없습니다"
searchText.isNotBlank() -> "'$searchText'에 대한 검색 결과가 없습니다"
else -> "표시할 핫딜이 없습니다"
}
EmptyState(
title = "결과가 없습니다",
title = "핫딜이 없습니다",
message = message,
icon = Icons.Outlined.Search
)
}
} else {
Surface(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
) {
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Outlined.Inventory2,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "${filteredDeals.size}개의 핫딜",
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (selectedSiteFilter != null) {
val filter = selectedSiteFilter!!
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "• ${filter.displayName}",
style = MaterialTheme.typography.labelMedium,
color = getSiteColor(filter)
)
}
}
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
contentPadding = PaddingValues(horizontal = 14.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(7.dp)
) {
items(
items = filteredDeals,
key = { it.id }
) { deal ->
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 8 }
),
exit = fadeOut(animationSpec = tween(200))
) {
DealItem(
deal = deal,
onClick = {
@@ -405,9 +388,7 @@ fun DealListScreen(
}
)
}
}
// EdgeToEdge: 하단 네비게이션 바 공간 확보
item {
Spacer(modifier = Modifier.height(16.dp))
}
@@ -424,26 +405,21 @@ fun DealListScreen(
}
}
// Pull to Refresh 인디케이터 - 절대 위치로 배치하여 간격 벌어짐 방지
val progress = pullToRefreshState.progress
val showIndicator = pullToRefreshState.isRefreshing || progress > 0
// Pull to Refresh 인디케이터
val topPadding = paddingValues.calculateTopPadding()
if (showIndicator) {
PullToRefreshContainer(
state = pullToRefreshState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 100.dp)
.padding(top = topPadding)
.zIndex(999f),
containerColor = MaterialTheme.colorScheme.primaryContainer,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer
containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.primary
)
}
// 새로고침 트리거
LaunchedEffect(pullToRefreshState.isRefreshing) {
if (pullToRefreshState.isRefreshing) {
isRefreshing = true
viewModel.refresh()
}
}
@@ -451,46 +427,53 @@ fun DealListScreen(
}
}
/**
* One UI 9 Compact Capsule Pill Filter Chip
*/
@Composable
private fun EnhancedFilterChip(
private fun FilterPillChip(
selected: Boolean,
onClick: () -> Unit,
label: String,
color: Color
accentColor: Color
) {
val scale by animateFloatAsState(
targetValue = if (selected) 1.05f else 1f,
animationSpec = spring(stiffness = Spring.StiffnessLow),
label = "chip_scale"
)
val bgColor = if (selected) {
accentColor.copy(alpha = 0.15f)
} else {
MaterialTheme.colorScheme.surface
}
val textColor = if (selected) {
accentColor
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
}
val borderColor = if (selected) {
accentColor.copy(alpha = 0.55f)
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f)
}
Surface(
onClick = onClick,
shape = CornerRadius.shapePill,
color = bgColor,
border = androidx.compose.foundation.BorderStroke(0.8.dp, borderColor),
modifier = Modifier
.height(36.dp)
.scale(scale),
shape = RoundedCornerShape(18.dp),
color = if (selected) color else MaterialTheme.colorScheme.surface,
border = if (!selected) androidx.compose.foundation.BorderStroke(1.dp, color.copy(alpha = 0.3f)) else null
.height(29.dp)
.elasticPressClickable(onClick = onClick)
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(horizontal = 11.dp, vertical = 4.dp)
) {
if (selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
Text(
text = label,
style = MaterialTheme.typography.labelLarge,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium,
color = if (selected) Color.White else color
fontSize = 11.5.sp
),
color = textColor
)
}
}
@@ -40,6 +40,9 @@ class MainActivity : ComponentActivity() {
@Inject
lateinit var workerScheduler: WorkerScheduler
@Inject
lateinit var scraperManager: com.hotdeal.alarm.domain.usecase.HotDealScraperManager
private var downloadReceiver: BroadcastReceiver? = null
override fun onCreate(savedInstanceState: Bundle?) {
@@ -216,7 +219,13 @@ class MainActivity : ComponentActivity() {
override fun onResume() {
super.onResume()
if (isInBackground) {
workerScheduler.executeOnce()
lifecycleScope.launch {
try {
scraperManager.fetchAndProcessDeals()
} catch (e: Exception) {
// 무시
}
}
}
isInBackground = false
}
@@ -5,32 +5,41 @@ import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import androidx.navigation.compose.rememberNavController
import kotlinx.coroutines.launch
import com.hotdeal.alarm.presentation.components.PermissionDialog
import com.hotdeal.alarm.presentation.components.elasticPressClickable
import com.hotdeal.alarm.presentation.deallist.DealListScreen
import com.hotdeal.alarm.presentation.settings.SettingsScreen
import com.hotdeal.alarm.ui.theme.CornerRadius
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class)
/**
* One UI 9 Docked Navigation Framework
* 바닥까지 완벽하게 밀착된 일체형 네이티브 하단 바
*/
@Composable
fun MainScreen(
viewModel: MainViewModel,
navController: NavHostController = rememberNavController()
) {
fun MainScreen(viewModel: MainViewModel) {
val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
var showPermissionDialog by remember { mutableStateOf(false) }
val notificationPermissionLauncher = rememberLauncherForActivityResult(
@@ -53,37 +62,119 @@ fun MainScreen(
}
}
val pagerState = rememberPagerState(
initialPage = 0,
pageCount = { 2 }
)
val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
val coroutineScope = rememberCoroutineScope()
var currentPage by remember { mutableStateOf(0) }
LaunchedEffect(pagerState) {
snapshotFlow { pagerState.currentPage }.collect { page ->
currentPage = page
}
}
Scaffold(
bottomBar = {
// One UI 9 Docked Bottom Navigation Bar (바닥과 완벽 일체화)
Column(
modifier = Modifier.fillMaxSize()
modifier = Modifier
.fillMaxWidth()
.background(MaterialTheme.colorScheme.surface)
) {
// 상단 초미세 헤어라인 구분선
HorizontalDivider(
thickness = 0.6.dp,
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
)
Row(
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
verticalAlignment = Alignment.CenterVertically
) {
val navItems = listOf(
Triple(0, "핫딜 피드", Icons.Filled.LocalFireDepartment to Icons.Outlined.LocalFireDepartment),
Triple(1, "설정 & 관리", Icons.Filled.Tune to Icons.Outlined.Tune)
)
navItems.forEach { (pageIndex, label, icons) ->
val isSelected = pagerState.currentPage == pageIndex
val (selectedIcon, unselectedIcon) = icons
val itemColor = if (isSelected) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f)
}
Box(
modifier = Modifier
.weight(1f)
.fillMaxHeight()
.elasticPressClickable {
coroutineScope.launch {
pagerState.animateScrollToPage(pageIndex)
}
},
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// 선택 시 상단 미니 캡슐 인디케이터
if (isSelected) {
Box(
modifier = Modifier
.width(18.dp)
.height(2.5.dp)
.clip(CornerRadius.shapePill)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.height(2.dp))
} else {
Spacer(modifier = Modifier.height(4.5.dp))
}
Icon(
imageVector = if (isSelected) selectedIcon else unselectedIcon,
contentDescription = label,
tint = itemColor,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.height(1.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 10.5.sp,
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium
),
color = itemColor
)
}
}
}
}
// 시스템 제스처 네비게이션 바 영역 패딩 (배경색은 일체형으로 유지)
Spacer(modifier = Modifier.navigationBarsPadding())
}
},
contentWindowInsets = WindowInsets(0, 0, 0, 0)
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) {
HorizontalPager(
state = pagerState,
modifier = Modifier.weight(1f)
modifier = Modifier.fillMaxSize(),
beyondBoundsPageCount = 1
) { page ->
when (page) {
0 -> {
DealListScreen(
0 -> DealListScreen(
viewModel = viewModel,
onNavigateToSettings = {
coroutineScope.launch { pagerState.animateScrollToPage(1) }
}
)
}
1 -> {
SettingsScreen(viewModel = viewModel)
1 -> SettingsScreen(viewModel = viewModel)
}
}
}
@@ -98,8 +189,3 @@ fun MainScreen(
)
}
}
sealed class Screen(val route: String) {
object DealList : Screen("deal_list")
object Settings : Screen("settings")
}
@@ -5,15 +5,16 @@ import androidx.lifecycle.viewModelScope
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.KeywordEntity
import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity
import com.hotdeal.alarm.data.local.preferences.AppSettings
import com.hotdeal.alarm.domain.model.Keyword
import com.hotdeal.alarm.domain.model.SiteType
import com.hotdeal.alarm.domain.usecase.HotDealScraperManager
import com.hotdeal.alarm.worker.WorkerScheduler
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import javax.inject.Inject
@HiltViewModel
@@ -21,6 +22,7 @@ class MainViewModel @Inject constructor(
private val hotDealDao: HotDealDao,
private val siteConfigDao: SiteConfigDao,
private val keywordDao: KeywordDao,
private val scraperManager: HotDealScraperManager,
private val workerScheduler: WorkerScheduler,
private val appSettings: AppSettings
) : ViewModel() {
@@ -28,39 +30,61 @@ class MainViewModel @Inject constructor(
private val _uiState = MutableStateFlow<MainUiState>(MainUiState.Loading)
val uiState: StateFlow<MainUiState> = _uiState.asStateFlow()
// 폴링 주기 (저장된 값 즉시 반영)
// 새로고침 진행 상태
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
// 폴링 주기
val pollingInterval: StateFlow<Int> = appSettings.pollingInterval
.stateIn(viewModelScope, SharingStarted.Eagerly, 2)
// 사이트 표시 순서
val siteOrder: StateFlow<List<SiteType>> = appSettings.siteOrder
.stateIn(viewModelScope, SharingStarted.Eagerly, SiteType.entries)
private val _toastEvent = MutableSharedFlow<String>()
val toastEvent: SharedFlow<String> = _toastEvent.asSharedFlow()
init {
initializeApp()
}
private fun initializeApp() {
// 1. 사이트 설정 초기화 후 DB Flow 구독 (별도 코루틴 — collect는 지속 실행)
viewModelScope.launch {
initializeDefaultSiteConfigs()
loadState()
// 저장된 폴� 주기로 시작
}
// 2. 백그라운드 폴링 등록 + 앱 진입 즉시 직접 1회 스크래핑 실행
viewModelScope.launch {
val savedInterval = appSettings.pollingInterval.first()
startPolling(savedInterval.toLong())
workerScheduler.schedulePeriodicPolling(savedInterval.toLong())
// 지연 없는 직접 스크래핑 실행 -> Room Flow를 통해 메인화면에 즉시 반영됨!
refresh()
}
}
private suspend fun initializeDefaultSiteConfigs() {
val existingConfigs = siteConfigDao.getAllConfigs()
if (existingConfigs.isEmpty()) {
val defaultConfigs = SiteType.entries.flatMap { site ->
val existingKeys = existingConfigs.map { it.siteBoardKey }.toSet()
val allDefaultConfigs = SiteType.entries.flatMap { site ->
site.boards.map { board ->
SiteConfigEntity(
siteBoardKey = "${site.name}_${board.id}",
siteName = site.name,
boardName = board.id,
displayName = "${site.displayName} - ${board.displayName}",
isEnabled = false
isEnabled = true // 기본 활성화 (설치 즉시 수집 가동)
)
}
}
siteConfigDao.insertConfigs(defaultConfigs)
val missingConfigs = allDefaultConfigs.filter { it.siteBoardKey !in existingKeys }
if (missingConfigs.isNotEmpty()) {
siteConfigDao.insertConfigs(missingConfigs)
}
}
@@ -68,12 +92,21 @@ class MainViewModel @Inject constructor(
combine(
hotDealDao.observeAllDeals(),
siteConfigDao.observeAllConfigs(),
keywordDao.observeAllKeywords()
) { deals, configs, keywords ->
keywordDao.observeAllKeywords(),
appSettings.keywordOrder
) { deals, configs, keywords, keywordOrder ->
val domainKeywords = keywords.map { it.toDomain() }
val sortedKeywords = if (keywordOrder.isEmpty()) {
domainKeywords
} else {
val orderMap = keywordOrder.withIndex().associate { it.value to it.index }
domainKeywords.sortedBy { orderMap[it.id] ?: Int.MAX_VALUE }
}
MainUiState.Success(
deals = deals.map { it.toDomain() },
siteConfigs = configs.map { it.toDomain() },
keywords = keywords.map { it.toDomain() }
keywords = sortedKeywords
)
}.catch { e ->
_uiState.value = MainUiState.Error(e.message ?: "Unknown error")
@@ -85,58 +118,100 @@ class MainViewModel @Inject constructor(
fun toggleSiteConfig(siteBoardKey: String, enabled: Boolean) {
viewModelScope.launch {
siteConfigDao.updateEnabled(siteBoardKey, enabled)
// 사이트 활성화 변경 시 즉시 새 핫딜 갱신
if (enabled) {
refresh()
}
}
}
fun reorderSites(fromIndex: Int, toIndex: Int) {
val current = siteOrder.value.toMutableList()
if (fromIndex in current.indices && toIndex in current.indices) {
val item = current.removeAt(fromIndex)
current.add(toIndex, item)
viewModelScope.launch {
appSettings.setSiteOrder(current)
}
}
}
fun addKeyword(keyword: String) {
if (keyword.isBlank()) return
viewModelScope.launch {
keywordDao.insertKeyword(
com.hotdeal.alarm.data.local.db.entity.KeywordEntity(
val newId = keywordDao.insertKeyword(
KeywordEntity(
keyword = keyword.trim(),
isEnabled = true
)
)
val currentOrder = appSettings.keywordOrder.first().toMutableList()
currentOrder.add(0, newId)
appSettings.setKeywordOrder(currentOrder)
// 키워드 등록 후 매칭 검사를 위해 즉시 갱신
refresh()
}
}
fun deleteKeyword(id: Long) {
viewModelScope.launch {
keywordDao.deleteKeywordById(id)
val currentOrder = appSettings.keywordOrder.first().toMutableList()
currentOrder.remove(id)
appSettings.setKeywordOrder(currentOrder)
}
}
fun toggleKeyword(id: Long, enabled: Boolean) {
viewModelScope.launch {
keywordDao.updateEnabled(id, enabled)
if (enabled) {
refresh()
}
}
}
fun reorderKeywords(fromIndex: Int, toIndex: Int) {
val state = _uiState.value as? MainUiState.Success ?: return
val current = state.keywords.toMutableList()
if (fromIndex in current.indices && toIndex in current.indices) {
val item = current.removeAt(fromIndex)
current.add(toIndex, item)
_uiState.value = state.copy(keywords = current)
viewModelScope.launch {
appSettings.setKeywordOrder(current.map { it.id })
}
}
}
/**
* 즐겨찾기 토글
*/
fun toggleFavorite(dealId: String) {
viewModelScope.launch {
hotDealDao.toggleFavorite(dealId)
}
}
/**
* 즐겨찾기 설정
*/
fun setFavorite(dealId: String, isFavorite: Boolean) {
viewModelScope.launch {
hotDealDao.setFavorite(dealId, isFavorite)
}
}
/**
* 직접 즉시 스크래핑 실행 (WorkManager 큐 대기 지연 없이 UI 및 DB 즉시 갱신)
*/
fun refresh() {
workerScheduler.executeOnce()
viewModelScope.launch {
_isRefreshing.value = true
try {
scraperManager.fetchAndProcessDeals()
} catch (e: Exception) {
// 에러 발생 시 무시 (UI는 DB Flow로 유지)
} finally {
_isRefreshing.value = false
}
}
}
/**
* 폴� 시작 (주기 저장)
*/
fun startPolling(intervalMinutes: Long = WorkerScheduler.DEFAULT_INTERVAL_MINUTES) {
viewModelScope.launch {
appSettings.setPollingInterval(intervalMinutes.toInt())
@@ -148,20 +223,20 @@ class MainViewModel @Inject constructor(
workerScheduler.cancelPolling()
}
// 데이터 파싱 핫딜 데이터 전체 삭제 및 사용자 피드백 트리거
private val _toastEvent = MutableSharedFlow<String>(extraBufferCapacity = 1)
val toastEvent = _toastEvent.asSharedFlow()
fun deleteAllParsedData() {
viewModelScope.launch {
try {
hotDealDao.deleteAllDeals()
_toastEvent.emit("파싱 데이터가 삭제되었습니다")
_toastEvent.emit("모든 수집 데이터가 삭제되었습니다")
} catch (e: Exception) {
_toastEvent.emit("데이터 삭제 중 오류가 발생했습니다: ${e.message}")
}
}
}
}
sealed class MainUiState {
data object Loading : MainUiState()
object Loading : MainUiState()
data class Success(
val deals: List<com.hotdeal.alarm.domain.model.HotDeal>,
val siteConfigs: List<com.hotdeal.alarm.domain.model.SiteConfig>,
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,48 @@
package com.hotdeal.alarm.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.hotdeal.alarm.data.local.preferences.AppSettings
import com.hotdeal.alarm.worker.WorkerScheduler
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* 부팅 완료 시 백그라운드 폴링을 자동으로 재등록하는 리시버
*/
@AndroidEntryPoint
class BootReceiver : BroadcastReceiver() {
@Inject
lateinit var workerScheduler: WorkerScheduler
@Inject
lateinit var appSettings: AppSettings
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == Intent.ACTION_BOOT_COMPLETED ||
intent.action == Intent.ACTION_MY_PACKAGE_REPLACED
) {
Log.d("BootReceiver", "부팅/업데이트 감지 → 백그라운드 폴링 재등록")
scope.launch {
try {
val interval = appSettings.pollingInterval.first()
workerScheduler.schedulePeriodicPolling(interval.toLong())
Log.d("BootReceiver", "폴링 재등록 완료: ${interval}분 간격")
} catch (e: Exception) {
Log.e("BootReceiver", "폴링 재등록 실패: ${e.message}")
workerScheduler.schedulePeriodicPolling(WorkerScheduler.DEFAULT_INTERVAL_MINUTES)
}
}
}
}
}
@@ -0,0 +1,71 @@
package com.hotdeal.alarm.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.hotdeal.alarm.data.local.preferences.AppSettings
import com.hotdeal.alarm.domain.usecase.HotDealScraperManager
import com.hotdeal.alarm.worker.AlarmScheduler
import com.hotdeal.alarm.worker.WorkerScheduler
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* 주기적 알람 수신 시 백그라운드 스크래핑을 즉시 실행하고 다음 알람을 예약하는 리시버
*
* goAsync()를 사용하여 WorkManager의 Doze 지연 없이 백그라운드에서 즉시 핫딜을 수집하고 알림을 발송합니다.
*/
@AndroidEntryPoint
class PollingAlarmReceiver : BroadcastReceiver() {
companion object {
private const val TAG = "PollingAlarmReceiver"
}
@Inject
lateinit var scraperManager: HotDealScraperManager
@Inject
lateinit var alarmScheduler: AlarmScheduler
@Inject
lateinit var appSettings: AppSettings
private val receiverScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onReceive(context: Context, intent: Intent) {
if (intent.action == AlarmScheduler.ACTION_POLL_ALARM) {
Log.d(TAG, "⏰ [실시간 알람 수신] 백그라운드 스크래핑 즉시 시작 및 다음 알람 예약")
val pendingResult = goAsync()
receiverScope.launch {
try {
// 1. 백그라운드에서 직접 스크래핑, DB 저장, 키워드 알림 즉시 발송!
val newDeals = scraperManager.fetchAndProcessDeals()
Log.d(TAG, "⏰ [스크래핑 완료] 신규 핫딜 ${newDeals.size}개 처리됨")
} catch (e: Exception) {
Log.e(TAG, "알람 처리 중 오류: ${e.message}", e)
} finally {
// 2. 다음 주기 알람 예약 (Self-rescheduling)
try {
val interval = appSettings.pollingInterval.first().toLong()
alarmScheduler.scheduleAlarm(interval)
Log.d(TAG, "⏰ 다음 폴링 알람 예약 완료: ${interval}분 후")
} catch (e: Exception) {
Log.e(TAG, "다음 알람 예약 실패: ${e.message}")
alarmScheduler.scheduleAlarm(WorkerScheduler.DEFAULT_INTERVAL_MINUTES)
}
pendingResult.finish()
}
}
}
}
}
@@ -1,34 +1,67 @@
package com.hotdeal.alarm.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp
/**
* One UI 9 & Material 3 Expressive 간격 시스템
*/
object Spacing {
val xxs = 2.dp
val xs = 4.dp
val sm = 8.dp
val md = 16.dp
val lg = 24.dp
val xl = 32.dp
val xxl = 48.dp
val md = 12.dp
val normal = 16.dp
val lg = 20.dp
val xl = 24.dp
val xxl = 32.dp
val xxxl = 48.dp
}
/**
* One UI 9 Squircle 고곡률 라운딩 시스템
*/
object CornerRadius {
val small = 8.dp
val medium = 12.dp
val large = 16.dp
val extraLarge = 24.dp
val full = 9999.dp
val micro = 6.dp
val small = 10.dp
val medium = 14.dp
val normal = 18.dp
val squircle = 22.dp
val large = 26.dp
val extraLarge = 32.dp
val pill = 999.dp
val shapeMicro = RoundedCornerShape(micro)
val shapeSmall = RoundedCornerShape(small)
val shapeMedium = RoundedCornerShape(medium)
val shapeNormal = RoundedCornerShape(normal)
val shapeSquircle = RoundedCornerShape(squircle)
val shapeLarge = RoundedCornerShape(large)
val shapeExtraLarge = RoundedCornerShape(extraLarge)
val shapePill = RoundedCornerShape(pill)
}
/**
* One UI 9 Subtle Elevation & Shadow
*/
object Elevation {
val none = 0.dp
val small = 2.dp
val ambient = 1.dp
val subtle = 2.dp
val medium = 4.dp
val large = 8.dp
val floating = 8.dp
val modal = 16.dp
}
/**
* One UI 9 Icon Size Hierarchy
*/
object IconSize {
val mini = 12.dp
val small = 16.dp
val medium = 24.dp
val large = 32.dp
val extraLarge = 48.dp
val medium = 20.dp
val normal = 24.dp
val large = 28.dp
val extraLarge = 36.dp
val hero = 48.dp
}
@@ -10,6 +10,7 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext
@@ -18,112 +19,131 @@ import androidx.core.view.WindowCompat
import com.hotdeal.alarm.domain.model.SiteType
// ============================================
// Material You Style - Premium Color Palette
// One UI 9 & Material 3 Expressive Color Palette
// ============================================
// Light Theme - Premium Palette
private val LightPrimary = Color(0xFF0061A4)
// Light Theme - Clean Frost & Vibrant Indigo
private val LightPrimary = Color(0xFF2563EB) // Electric Indigo
private val LightOnPrimary = Color(0xFFFFFFFF)
private val LightPrimaryContainer = Color(0xFFD1E4FF)
private val LightOnPrimaryContainer = Color(0xFF001D36)
private val LightPrimaryContainer = Color(0xFFDBEAFE)
private val LightOnPrimaryContainer = Color(0xFF1E3A8A)
private val LightSecondary = Color(0xFF535F70)
private val LightSecondary = Color(0xFF475569) // Slate
private val LightOnSecondary = Color(0xFFFFFFFF)
private val LightSecondaryContainer = Color(0xFFD7E3F8)
private val LightOnSecondaryContainer = Color(0xFF101C2B)
private val LightSecondaryContainer = Color(0xFFF1F5F9)
private val LightOnSecondaryContainer = Color(0xFF0F172A)
private val LightTertiary = Color(0xFF6B5778)
private val LightTertiary = Color(0xFF0D9488) // Modern Teal
private val LightOnTertiary = Color(0xFFFFFFFF)
private val LightTertiaryContainer = Color(0xFFF2DAFF)
private val LightOnTertiaryContainer = Color(0xFF251431)
private val LightTertiaryContainer = Color(0xFFCCFBF1)
private val LightOnTertiaryContainer = Color(0xFF115E59)
private val LightBackground = Color(0xFFFDFCFF)
private val LightOnBackground = Color(0xFF1A1C1E)
private val LightSurface = Color(0xFFFDFCFF)
private val LightOnSurface = Color(0xFF1A1C1E)
private val LightSurfaceVariant = Color(0xFFDFE2EB)
private val LightOnSurfaceVariant = Color(0xFF43474E)
private val LightBackground = Color(0xFFF8FAFC) // Clean Ultra Frost
private val LightOnBackground = Color(0xFF0F172A)
private val LightSurface = Color(0xFFFFFFFF)
private val LightOnSurface = Color(0xFF0F172A)
private val LightSurfaceVariant = Color(0xFFF1F5F9)
private val LightOnSurfaceVariant = Color(0xFF475569)
private val LightError = Color(0xFFBA1A1A)
private val LightError = Color(0xFFDC2626)
private val LightOnError = Color(0xFFFFFFFF)
private val LightErrorContainer = Color(0xFFFFDAD6)
private val LightOnErrorContainer = Color(0xFF410002)
private val LightErrorContainer = Color(0xFFFEE2E2)
private val LightOnErrorContainer = Color(0xFF991B1B)
private val LightOutline = Color(0xFF73777F)
private val LightOutlineVariant = Color(0xFFC3C6CF)
private val LightInverseSurface = Color(0xFF2F3033)
private val LightInverseOnSurface = Color(0xFFF1F0F4)
private val LightInversePrimary = Color(0xFF9ECAFF)
private val LightSurfaceTint = Color(0xFF0061A4)
private val LightOutline = Color(0xFF94A3B8)
private val LightOutlineVariant = Color(0xFFE2E8F0)
private val LightInverseSurface = Color(0xFF0F172A)
private val LightInverseOnSurface = Color(0xFFF8FAFC)
private val LightInversePrimary = Color(0xFF60A5FA)
private val LightSurfaceTint = Color(0xFF2563EB)
// Dark Theme - Premium Palette
private val DarkPrimary = Color(0xFF9ECAFF)
private val DarkOnPrimary = Color(0xFF003258)
private val DarkPrimaryContainer = Color(0xFF00497D)
private val DarkOnPrimaryContainer = Color(0xFFD1E4FF)
// Dark Theme - Deep OLED Slate (One UI 9)
private val DarkPrimary = Color(0xFF60A5FA) // Luminous Indigo
private val DarkOnPrimary = Color(0xFF0F172A)
private val DarkPrimaryContainer = Color(0xFF1E3A8A)
private val DarkOnPrimaryContainer = Color(0xFFDBEAFE)
private val DarkSecondary = Color(0xFFBBC7DB)
private val DarkOnSecondary = Color(0xFF253140)
private val DarkSecondaryContainer = Color(0xFF3B4858)
private val DarkOnSecondaryContainer = Color(0xFFD7E3F8)
private val DarkSecondary = Color(0xFF94A3B8)
private val DarkOnSecondary = Color(0xFF0F172A)
private val DarkSecondaryContainer = Color(0xFF1E293B)
private val DarkOnSecondaryContainer = Color(0xFFF1F5F9)
private val DarkTertiary = Color(0xFFD6BEE4)
private val DarkOnTertiary = Color(0xFF3B2948)
private val DarkTertiaryContainer = Color(0xFF523F5F)
private val DarkOnTertiaryContainer = Color(0xFFF2DAFF)
private val DarkTertiary = Color(0xFF2DD4BF)
private val DarkOnTertiary = Color(0xFF042F2E)
private val DarkTertiaryContainer = Color(0xFF115E59)
private val DarkOnTertiaryContainer = Color(0xFFCCFBF1)
private val DarkBackground = Color(0xFF1A1C1E)
private val DarkOnBackground = Color(0xFFE2E2E6)
private val DarkSurface = Color(0xFF1A1C1E)
private val DarkOnSurface = Color(0xFFE2E2E6)
private val DarkSurfaceVariant = Color(0xFF43474E)
private val DarkOnSurfaceVariant = Color(0xFFC3C6CF)
private val DarkBackground = Color(0xFF0B0F19) // Deep OLED Slate
private val DarkOnBackground = Color(0xFFF1F5F9)
private val DarkSurface = Color(0xFF111827) // Elev 1
private val DarkOnSurface = Color(0xFFF1F5F9)
private val DarkSurfaceVariant = Color(0xFF1F2937) // Elev 2
private val DarkOnSurfaceVariant = Color(0xFF9CA3AF)
private val DarkError = Color(0xFFFFB4AB)
private val DarkOnError = Color(0xFF690005)
private val DarkErrorContainer = Color(0xFF93000A)
private val DarkOnErrorContainer = Color(0xFFFFDAD6)
private val DarkError = Color(0xFFF87171)
private val DarkOnError = Color(0xFF450A0A)
private val DarkErrorContainer = Color(0xFF7F1D1D)
private val DarkOnErrorContainer = Color(0xFFFECACA)
private val DarkOutline = Color(0xFF8D9199)
private val DarkOutlineVariant = Color(0xFF43474E)
private val DarkInverseSurface = Color(0xFFE2E2E6)
private val DarkInverseOnSurface = Color(0xFF2F3033)
private val DarkInversePrimary = Color(0xFF0061A4)
private val DarkSurfaceTint = Color(0xFF9ECAFF)
private val DarkOutline = Color(0xFF4B5563)
private val DarkOutlineVariant = Color(0xFF374151)
private val DarkInverseSurface = Color(0xFFF1F5F9)
private val DarkInverseOnSurface = Color(0xFF0F172A)
private val DarkInversePrimary = Color(0xFF2563EB)
private val DarkSurfaceTint = Color(0xFF60A5FA)
// ============================================
// Site Colors - Premium Tones
// One UI 9 Premium Accents & Brand Tones
// ============================================
// 뽐뿌 - Premium Pink
val PpomppuColor = Color(0xFFE91E63)
val PpomppuColorLight = Color(0xFFFCE4EC)
val PpomppuColorDark = Color(0xFFC2185B)
// 뽐뿌 - Rose Magenta
val PpomppuColor = Color(0xFFE11D48)
val PpomppuColorLight = Color(0xFFFFF1F2)
val PpomppuColorDark = Color(0xFF881337)
// 클리앙 - Premium Green
val ClienColor = Color(0xFF2E7D32)
val ClienColorLight = Color(0xFFE8F5E9)
val ClienColorDark = Color(0xFF1B5E20)
// 클리앙 - Emerald Green
val ClienColor = Color(0xFF059669)
val ClienColorLight = Color(0xFFECFDF5)
val ClienColorDark = Color(0xFF064E3B)
// 루리웹 - Premium Blue
val RuriwebColor = Color(0xFF1565C0)
val RuriwebColorLight = Color(0xFFE3F2FD)
val RuriwebColorDark = Color(0xFF0D47A1)
// 루리웹 - Royal Cobalt
val RuriwebColor = Color(0xFF2563EB)
val RuriwebColorLight = Color(0xFFEFF6FF)
val RuriwebColorDark = Color(0xFF1E3A8A)
// 쿨엔조이 - Premium Orange
val CoolenjoyColor = Color(0xFFEF6C00)
val CoolenjoyColorLight = Color(0xFFFFF3E0)
val CoolenjoyColorDark = Color(0xFFE65100)
// 쿨엔조이 - Neon Sunset Orange
val CoolenjoyColor = Color(0xFFEA580C)
val CoolenjoyColorLight = Color(0xFFFFF7ED)
val CoolenjoyColorDark = Color(0xFF7C2D12)
// Keyword Match - Light Red Theme
val KeywordMatchColor = Color(0xFFE53935)
val KeywordMatchColorLight = Color(0xFFFFEBEE)
// 아카라이브 - Vibrant Emerald Teal
val ArcaLiveColor = Color(0xFF0D9488)
val ArcaLiveColorLight = Color(0xFFF0FDFA)
val ArcaLiveColorDark = Color(0xFF134E4A)
// Favorite - Premium Red
val FavoriteColor = Color(0xFFE53935)
// Hot / Popular Flame
val HotDealFlameColor = Color(0xFFFF5722)
val HotDealFlameGradient = Brush.horizontalGradient(
listOf(Color(0xFFFF3D00), Color(0xFFFF9100))
)
// Keyword Match - Amber Gold Luxury Glow
val KeywordGold = Color(0xFFF59E0B)
val KeywordGoldLight = Color(0xFFFEF3C7)
val KeywordGoldDark = Color(0xFF78350F)
val KeywordGoldGradient = Brush.horizontalGradient(
listOf(Color(0xFFF59E0B), Color(0xFFD97706))
)
// Favorite - Premium Crimson Heart
val FavoriteColor = Color(0xFFEF4444)
// One UI 9 Micro Rim Border Color
val AmbientBorderColorLight = Color(0x1F0F172A)
val AmbientBorderColorDark = Color(0x33FFFFFF)
/**
* 사이트별 색상 가져오기
* 사이트별 대표 색상
*/
fun getSiteColor(siteType: SiteType?): Color {
return when (siteType) {
@@ -131,12 +151,13 @@ fun getSiteColor(siteType: SiteType?): Color {
SiteType.CLIEN -> ClienColor
SiteType.RURIWEB -> RuriwebColor
SiteType.COOLENJOY -> CoolenjoyColor
null -> Color.Gray
SiteType.ARCALIVE -> ArcaLiveColor
null -> Color(0xFF64748B)
}
}
/**
* 사이트별 라이트 배경색 가져오기
* 사이트별 라이트 소프트 틴트
*/
fun getSiteColorLight(siteType: SiteType?): Color {
return when (siteType) {
@@ -144,12 +165,13 @@ fun getSiteColorLight(siteType: SiteType?): Color {
SiteType.CLIEN -> ClienColorLight
SiteType.RURIWEB -> RuriwebColorLight
SiteType.COOLENJOY -> CoolenjoyColorLight
null -> Color.LightGray.copy(alpha = 0.3f)
SiteType.ARCALIVE -> ArcaLiveColorLight
null -> Color(0xFFF1F5F9)
}
}
/**
* 사이트별 다크 배경색 가져오기
* 사이트별 다크 서브 틴트
*/
fun getSiteColorDark(siteType: SiteType?): Color {
return when (siteType) {
@@ -157,7 +179,8 @@ fun getSiteColorDark(siteType: SiteType?): Color {
SiteType.CLIEN -> ClienColorDark
SiteType.RURIWEB -> RuriwebColorDark
SiteType.COOLENJOY -> CoolenjoyColorDark
null -> Color.DarkGray
SiteType.ARCALIVE -> ArcaLiveColorDark
null -> Color(0xFF1E293B)
}
}
@@ -246,9 +269,9 @@ fun HotDealTheme(
if (!view.isInEditMode) {
SideEffect {
val window = (view.context as Activity).window
// 시스템 바 색상을 앱 배경색과 일치시켜 이질감 제거
window.statusBarColor = colorScheme.surface.toArgb()
window.navigationBarColor = colorScheme.surface.toArgb()
// One UI 9 Edge-to-Edge 완벽 투명 처리
window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme
}
@@ -7,133 +7,158 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
/**
* Premium Typography System
* Material You 스타일의 세련된 타이포그래피
* One UI 9 & Material 3 Expressive Typography System
* 선명한 시각적 위계와 고가독성을 제공하는 타이포그래피
*/
val AppTypography = Typography(
// ============================================
// Display - 대형 헤더 (사용 빈도 낮음)
// ============================================
displayLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Light,
fontSize = 57.sp,
lineHeight = 64.sp,
letterSpacing = (-0.25).sp
fontWeight = FontWeight.Bold,
fontSize = 40.sp,
lineHeight = 48.sp,
letterSpacing = (-0.5).sp
),
displayMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Light,
fontSize = 45.sp,
lineHeight = 52.sp,
letterSpacing = 0.sp
fontWeight = FontWeight.Bold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = (-0.4).sp
),
displaySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 36.sp,
lineHeight = 44.sp,
letterSpacing = 0.sp
fontWeight = FontWeight.SemiBold,
fontSize = 26.sp,
lineHeight = 34.sp,
letterSpacing = (-0.3).sp
),
// ============================================
// Headline - 중형 헤더
// ============================================
headlineLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 32.sp,
lineHeight = 40.sp,
letterSpacing = 0.sp
fontWeight = FontWeight.Bold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = (-0.3).sp
),
headlineMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 28.sp,
lineHeight = 36.sp,
letterSpacing = 0.sp
fontWeight = FontWeight.Bold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = (-0.25).sp
),
headlineSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 24.sp,
lineHeight = 32.sp,
letterSpacing = 0.sp
fontSize = 20.sp,
lineHeight = 28.sp,
letterSpacing = (-0.2).sp
),
// ============================================
// Title - 화면/섹션 타이틀
// ============================================
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
fontWeight = FontWeight.Bold,
fontSize = 20.sp,
lineHeight = 26.sp,
letterSpacing = (-0.2).sp
),
titleMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.15.sp
lineHeight = 22.sp,
letterSpacing = (-0.15).sp
),
titleSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
letterSpacing = (-0.1).sp
),
// ============================================
// Body - 본문 텍스트
// ============================================
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
fontSize = 15.sp,
lineHeight = 22.sp,
letterSpacing = (-0.1).sp
),
bodyMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.25.sp
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = (-0.05).sp
),
bodySmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.4.sp
letterSpacing = 0.sp
),
// ============================================
// Label - 버튼, 칩, 뱃지 등
// ============================================
labelLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 14.sp,
lineHeight = 20.sp,
letterSpacing = 0.1.sp
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
lineHeight = 18.sp,
letterSpacing = (-0.1).sp
),
labelMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 14.sp,
letterSpacing = 0.sp
)
)
/**
* One UI 9 Text Spotlight 전용 스타일 확장
*/
object SpotlightTypography {
// 핫딜 가격 초대형 강조
val priceLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.ExtraBold,
fontSize = 18.sp,
lineHeight = 24.sp,
letterSpacing = (-0.4).sp
)
val priceMedium = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 15.sp,
lineHeight = 20.sp,
letterSpacing = (-0.2).sp
)
// 사이트 및 카테고리 뱃지 라벨
val badge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Bold,
fontSize = 11.5.sp,
lineHeight = 14.sp,
letterSpacing = (-0.1).sp
)
// 상태 요약 캡슐 라벨
val statusSummary = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
letterSpacing = (-0.1).sp
)
}
@@ -25,7 +25,6 @@ object ApkDownloadManager {
private const val TAG = "ApkDownloadManager"
private const val APK_FILE_NAME = "hotdeal-alarm-update.apk"
// 등록된 리시버 추적 (메모리 누수 방지)
private var registeredReceiver: BroadcastReceiver? = null
/**
@@ -34,31 +33,29 @@ object ApkDownloadManager {
fun downloadApk(context: Context, updateInfo: UpdateInfo): Long {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
// 기존 파일 삭제
val outputFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), APK_FILE_NAME)
val outputDir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
if (outputDir != null && !outputDir.exists()) {
outputDir.mkdirs()
}
val outputFile = File(outputDir, APK_FILE_NAME)
if (outputFile.exists()) {
outputFile.delete()
}
val request = DownloadManager.Request(Uri.parse(updateInfo.updateUrl)).apply {
setTitle("핫딜 알람 업데이트")
setDescription("버전 ${updateInfo.version} 다운로드 중...")
setDescription("v${updateInfo.version} 다운로드 중...")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, APK_FILE_NAME)
setAllowedOverMetered(true)
setAllowedOverRoaming(true)
setMimeType("application/vnd.android.package-archive")
// Wi-Fi 환경에서 다운로드 우선
setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
}
val downloadId = downloadManager.enqueue(request)
Toast.makeText(
context,
"업데이트 다운로드 시작...",
Toast.LENGTH_SHORT
).show()
Log.d(TAG, "다운로드 큐에 추가됨: downloadId=$downloadId, url=${updateInfo.updateUrl}")
return downloadId
}
@@ -72,14 +69,13 @@ object ApkDownloadManager {
onComplete: () -> Unit,
onFailed: () -> Unit
): BroadcastReceiver {
// 기존 리시버가 있으면 먼저 해제
unregisterDownloadCompleteReceiver(context)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
override fun onReceive(receivedContext: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: -1
if (id == downloadId) {
val downloadManager = context?.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
val downloadManager = receivedContext?.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor = downloadManager?.query(query)
@@ -90,16 +86,14 @@ object ApkDownloadManager {
when (status) {
DownloadManager.STATUS_SUCCESSFUL -> {
Log.d(TAG, "다운로드 완료, 설치 시작")
Log.d(TAG, "다운로드 완료 감지, onComplete 호출")
onComplete()
// 설치 후 리시버 해제
unregisterDownloadCompleteReceiver(context)
unregisterDownloadCompleteReceiver(receivedContext ?: context)
}
DownloadManager.STATUS_FAILED -> {
Log.e(TAG, "다운로드 실패")
Log.e(TAG, "다운로드 실패 감지")
onFailed()
// 실패 시 리시버 해제
unregisterDownloadCompleteReceiver(context)
unregisterDownloadCompleteReceiver(receivedContext ?: context)
}
}
}
@@ -108,12 +102,11 @@ object ApkDownloadManager {
}
}
// Android 12+ 에서는 RECEIVER_NOT_EXPORTED 플래그 필요
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(
receiver,
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
Context.RECEIVER_NOT_EXPORTED
Context.RECEIVER_EXPORTED
)
} else {
context.registerReceiver(
@@ -123,29 +116,20 @@ object ApkDownloadManager {
}
registeredReceiver = receiver
Log.d(TAG, "다운로드 리시버 등록됨, downloadId=$downloadId")
return receiver
}
/**
* 다운로드 완료 리시버 해제
*/
fun unregisterDownloadCompleteReceiver(context: Context) {
registeredReceiver?.let { receiver ->
try {
context.unregisterReceiver(receiver)
Log.d(TAG, "다운로드 리시버 해제됨")
} catch (e: Exception) {
Log.w(TAG, "리시버 해제 실패 (이미 해제됨): ${e.message}")
Log.w(TAG, "리시버 해제 중 예외: ${e.message}")
}
registeredReceiver = null
}
}
/**
* 다운로드 상태 확인 (suspend 함수)
*/
suspend fun getDownloadStatus(context: Context, downloadId: Long): DownloadStatus =
withContext(Dispatchers.IO) {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
@@ -161,8 +145,8 @@ object ApkDownloadManager {
val bytesTotalIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
val downloadStatus = it.getInt(statusIndex)
val bytesDownloaded = it.getLong(bytesDownloadedIndex)
val bytesTotal = it.getLong(bytesTotalIndex)
val bytesDownloaded = if (bytesDownloadedIndex >= 0) it.getLong(bytesDownloadedIndex) else 0L
val bytesTotal = if (bytesTotalIndex >= 0) it.getLong(bytesTotalIndex) else 0L
val progress = if (bytesTotal > 0) {
((bytesDownloaded * 100) / bytesTotal).toInt()
@@ -181,26 +165,27 @@ object ApkDownloadManager {
}
/**
* 다운로드 완료 대기 (suspend 함수)
*/
suspend fun waitForDownload(context: Context, downloadId: Long): Boolean {
while (true) {
val status = getDownloadStatus(context, downloadId)
if (status.isComplete) return true
if (status.isFailed) return false
delay(500)
}
}
/**
* APK 파일 설치
* @return Boolean true if installation started, false if permission denied
* APK 파일 설치 화면 실행
*/
fun installApk(context: Context): Boolean {
// Android 8.0 이상에서 알 수 없는 앱 설치 권한 확인
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (!context.packageManager.canRequestPackageInstalls()) {
Log.w(TAG, "앱 설치 권한 없음")
Toast.makeText(context, "설치 권한이 필요합니다. 설정에서 허용해주세요.", Toast.LENGTH_SHORT).show()
Log.w(TAG, "앱 설치 권한 없음 -> 설정 화면 이동")
Toast.makeText(context, "앱 설치 권한을 허용한 후 다시 시도해주세요.", Toast.LENGTH_LONG).show()
try {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
} catch (e: Exception) {
val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
return false
}
}
@@ -208,7 +193,8 @@ object ApkDownloadManager {
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), APK_FILE_NAME)
if (!apkFile.exists()) {
Toast.makeText(context, "APK 파일을 찾을 수 없습니다", Toast.LENGTH_SHORT).show()
Log.e(TAG, "APK 파일이 존재하지 않음: ${apkFile.absolutePath}")
Toast.makeText(context, "APK 파일을 찾을 수 없습니다. 다시 다운로드해주세요.", Toast.LENGTH_SHORT).show()
return false
}
@@ -219,23 +205,23 @@ object ApkDownloadManager {
apkFile
)
Log.d(TAG, "설치 인텐트 시작: uri=$apkUri")
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(apkUri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
}
context.startActivity(intent)
return true
} catch (e: Exception) {
Toast.makeText(context, "설치를 시작할 수 없습니다: ${e.message}", Toast.LENGTH_SHORT).show()
Log.e(TAG, "설치 화면 호출 실패", e)
Toast.makeText(context, "설치 화면을 열 수 없습니다: ${e.message}", Toast.LENGTH_LONG).show()
return false
}
}
/**
* 다운로드 상태 데이터 클래스
*/
data class DownloadStatus(
val progress: Int,
val bytesDownloaded: Long,
@@ -0,0 +1,116 @@
package com.hotdeal.alarm.worker
import android.app.AlarmManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.util.Log
import com.hotdeal.alarm.receiver.PollingAlarmReceiver
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* 실시간 백그라운드 폴링을 위한 AlarmManager 스케줄러
*
* WorkManager PeriodicWork의 15분 주기 제한 및 Doze 모드 지연을 극복하기 위해
* AlarmManager (setExactAndAllowWhileIdle / setAndAllowWhileIdle)를 사용하여
* 사용자가 설정한 주기(1분, 2분, 5분 등)대로 정확하게 백그라운드 수집을 보장합니다.
*/
@Singleton
class AlarmScheduler @Inject constructor(
@ApplicationContext private val context: Context
) {
companion object {
private const val TAG = "AlarmScheduler"
const val ACTION_POLL_ALARM = "com.hotdeal.alarm.ACTION_POLL_ALARM"
private const val REQUEST_CODE = 2001
}
private val alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
/**
* 알람 예약 (Doze mode에서도 정확히 깨어남)
* @param intervalMinutes 폴링 간격 (분)
*/
fun scheduleAlarm(intervalMinutes: Long) {
val safeInterval = intervalMinutes.coerceAtLeast(1L)
val triggerTime = System.currentTimeMillis() + (safeInterval * 60 * 1000L)
val intent = Intent(context, PollingAlarmReceiver::class.java).apply {
action = ACTION_POLL_ALARM
}
val pendingIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
if (alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
)
Log.d(TAG, "Exact Alarm 예약 완료: ${safeInterval}분 후 (${triggerTime})")
} else {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
)
Log.d(TAG, "Inexact Alarm 예약 완료: ${safeInterval}분 후 (권한 없음)")
}
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
)
Log.d(TAG, "Exact Alarm 예약 완료 (API < 31): ${safeInterval}분 후")
} else {
alarmManager.setExact(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
)
}
} catch (e: Exception) {
Log.e(TAG, "Alarm 예약 실패: ${e.message}", e)
try {
alarmManager.setAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerTime,
pendingIntent
)
} catch (fallbackEx: Exception) {
Log.e(TAG, "Alarm 폴백 예약도 실패: ${fallbackEx.message}")
}
}
}
/**
* 알람 취소
*/
fun cancelAlarm() {
val intent = Intent(context, PollingAlarmReceiver::class.java).apply {
action = ACTION_POLL_ALARM
}
val pendingIntent = PendingIntent.getBroadcast(
context,
REQUEST_CODE,
intent,
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE
)
if (pendingIntent != null) {
alarmManager.cancel(pendingIntent)
pendingIntent.cancel()
Log.d(TAG, "Alarm 취소 완료")
}
}
}
@@ -6,33 +6,20 @@ import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.ForegroundInfo
import androidx.work.WorkerParameters
import com.hotdeal.alarm.data.local.db.entity.HotDealEntity
import com.hotdeal.alarm.data.local.db.entity.KeywordEntity
import com.hotdeal.alarm.data.local.db.entity.SiteConfigEntity
import com.hotdeal.alarm.data.remote.scraper.ScraperFactory
import com.hotdeal.alarm.domain.model.HotDeal
import com.hotdeal.alarm.domain.usecase.HotDealScraperManager
import com.hotdeal.alarm.service.NotificationService
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.withContext
/**
* 핫딜 폴� Worker
*
* 키워드 매칭된 핫딜만 알림 발송
* 핫딜 폴링 Worker (WorkManager 전용)
*/
@HiltWorker
class HotDealPollingWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val scraperFactory: ScraperFactory,
private val notificationService: NotificationService,
private val dealDao: com.hotdeal.alarm.data.local.db.dao.HotDealDao,
private val siteConfigDao: com.hotdeal.alarm.data.local.db.dao.SiteConfigDao,
private val keywordDao: com.hotdeal.alarm.data.local.db.dao.KeywordDao
private val scraperManager: HotDealScraperManager,
private val notificationService: NotificationService
) : CoroutineWorker(context, workerParams) {
companion object {
@@ -40,146 +27,18 @@ class HotDealPollingWorker @AssistedInject constructor(
const val TAG = "HotDealPolling"
}
override suspend fun doWork(): Result = withContext(Dispatchers.IO) {
try {
Log.d(TAG, "===== 핫딜 폴� 시작 =====")
// 1. 활성화된 사이트 설정 조회
val enabledConfigs = siteConfigDao.getEnabledConfigs()
Log.d(TAG, "활성화된 사이트 설정: ${enabledConfigs.size}개")
if (enabledConfigs.isEmpty()) {
Log.w(TAG, "활성화된 사이트 설정이 없습니다.")
return@withContext Result.success()
}
// 2. 활성화된 키워드 조회
val keywords = keywordDao.getEnabledKeywords()
Log.d(TAG, "활성화된 키워드: ${keywords.size}개")
// 3. 각 사이트/게시판 스크래핑 (병렬)
Log.d(TAG, "스크래핑 시작...")
val allDeals = enabledConfigs.map { config ->
async {
scrapeSite(config)
}
}.awaitAll().flatten()
Log.d(TAG, "스크래핑 완료: 총 ${allDeals.size}개 핫딜")
if (allDeals.isEmpty()) {
Log.w(TAG, "스크래핑된 핫딜이 없습니다.")
return@withContext Result.success()
}
// 4. 새로운 핫딜 필터링
val newDeals = filterNewDeals(allDeals)
Log.d(TAG, "새로운 핫딜: ${newDeals.size}개")
if (newDeals.isEmpty()) {
Log.d(TAG, "새로운 핫딜이 없습니다.")
return@withContext Result.success()
}
// 5. 키워드 매칭 (키워드가 있을 때만)
val keywordMatchedDeals = if (keywords.isNotEmpty()) {
filterByKeywords(newDeals, keywords).also {
Log.d(TAG, "키워드 매칭 핫딜: ${it.size}개")
}
} else {
Log.d(TAG, "키워드가 없어 매칭 체크를 건�뜁니다.")
emptyList()
}
// 6. DB 저장
var insertedCount = 0
newDeals.forEach { deal ->
try {
val entity = HotDealEntity.fromDomain(deal)
dealDao.insertDeal(entity)
insertedCount++
} catch (e: Exception) {
Log.w(TAG, "핫딜 저장 실패 (무시됨): ${deal.id}")
}
}
Log.d(TAG, "DB 저장 완료: $insertedCount/${newDeals.size}개 저장됨")
// 7. 키워드 매칭 알림 발송 (키워드 매칭된 것만!)
if (keywordMatchedDeals.isNotEmpty()) {
Log.d(TAG, "키워드 매칭 알림 발송: ${keywordMatchedDeals.size}개")
notificationService.showKeywordMatchNotification(keywordMatchedDeals)
dealDao.markAsKeywordMatch(keywordMatchedDeals.map { it.id })
} else {
Log.d(TAG, "키워드 매칭된 핫딜이 없어 알림을 발송하지 않습니다.")
}
// 8. 알림 발송 상태 업데이트
dealDao.markAsNotified(newDeals.map { it.id })
// 9. 오래된 데이터 정리 (3일 이상 - 배터리/데이터 절약)
val threshold = System.currentTimeMillis() - (3 * 24 * 60 * 60 * 1000L)
dealDao.deleteOldDeals(threshold)
Log.d(TAG, "===== 핫딜 폴� 완료 =====")
override suspend fun doWork(): Result {
Log.d(TAG, "WorkManager HotDealPollingWorker 실행됨")
return try {
scraperManager.fetchAndProcessDeals()
Result.success()
} catch (e: Exception) {
Log.e(TAG, "폴� 오류: ${e.message}", e)
if (runAttemptCount < 3) {
Result.retry()
} else {
Result.failure()
}
Log.e(TAG, "Worker 실행 실패: ${e.message}", e)
if (runAttemptCount < 3) Result.retry() else Result.failure()
}
}
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) {
Log.w(TAG, "스크래퍼를 찾을 수 없음: ${config.siteName}")
return emptyList()
}
Log.d(TAG, "스크래핑: ${config.siteName}/${config.boardName}")
val result = scraper.scrape(config.boardName)
result.fold(
onSuccess = { deals ->
Log.d(TAG, " ${config.siteName}: ${deals.size}개 핫딜 파싱됨")
try {
siteConfigDao.updateLastScrapedAt(config.siteBoardKey, System.currentTimeMillis())
} catch (e: Exception) {
Log.w(TAG, "lastScrapedAt 업데이트 실패: ${config.siteBoardKey}")
}
},
onFailure = { error ->
Log.e(TAG, " ${config.siteName} 스크래핑 실패: ${error.message}")
}
)
return result.getOrElse { emptyList() }
}
private suspend fun filterNewDeals(deals: List<HotDeal>): List<HotDeal> {
return deals.filter { deal ->
val existingByUrl = dealDao.getDealByUrl(deal.url)
if (existingByUrl != null) return@filter false
val existingById = dealDao.getDealById(deal.id)
if (existingById != null) return@filter false
true
}
}
private fun filterByKeywords(deals: List<HotDeal>, keywords: List<KeywordEntity>): List<HotDeal> {
return deals.filter { deal ->
keywords.any { keyword ->
deal.title.contains(keyword.keyword, ignoreCase = true)
}
}
}
}
@@ -1,6 +1,7 @@
package com.hotdeal.alarm.worker
import android.content.Context
import android.util.Log
import androidx.work.*
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.concurrent.TimeUnit
@@ -8,35 +9,40 @@ import javax.inject.Inject
import javax.inject.Singleton
/**
* Worker 스케줄러
* 실시간 백그라운드 폴링 통합 스케줄러
*
* 기본 폴� 주기: 2분
* 배터리/데이터 절약을 위해 최소 2분 권장
* 1. AlarmManager (AlarmScheduler): Doze Mode에서도 사용자가 지정한 정확한 주기(1분, 2분 등)로 즉시 실행
* 2. WorkManager (HotDealPollingWorker): OS 백그라운드 작업 관리 및 백업 실행
*/
@Singleton
class WorkerScheduler @Inject constructor(
@ApplicationContext private val context: Context
@ApplicationContext private val context: Context,
private val alarmScheduler: AlarmScheduler
) {
companion object {
private const val TAG = "WorkerScheduler"
const val DEFAULT_INTERVAL_MINUTES = 2L
const val MIN_INTERVAL_MINUTES = 1L
const val MAX_INTERVAL_MINUTES = 60L
}
/**
* 주기적 폴� 예약
* @param intervalMinutes 폴� 간격 (분) - 기본 2분
* 주기적 폴링 예약 (AlarmManager + WorkManager 이중 등록)
* @param intervalMinutes 폴링 간격 (분)
*/
fun schedulePeriodicPolling(intervalMinutes: Long = DEFAULT_INTERVAL_MINUTES) {
// 최소/최대 값 제한
val safeInterval = intervalMinutes.coerceIn(MIN_INTERVAL_MINUTES, MAX_INTERVAL_MINUTES)
Log.d(TAG, "주기적 폴링 예약 시작: ${safeInterval}분 간격")
// 1. AlarmManager 기반 정확한 주기 알람 예약 (Doze mode 관통)
alarmScheduler.scheduleAlarm(safeInterval)
// 2. WorkManager 기반 백업 주기 작업 예약
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.setRequiresDeviceIdle(false)
.build()
// 유연한 실행 시간 (±25%)
val flexTime = (safeInterval * 0.25).toLong().coerceAtLeast(1)
val workRequest = PeriodicWorkRequestBuilder<HotDealPollingWorker>(
@@ -54,21 +60,23 @@ class WorkerScheduler @Inject constructor(
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
HotDealPollingWorker.WORK_NAME,
ExistingPeriodicWorkPolicy.KEEP,
ExistingPeriodicWorkPolicy.UPDATE,
workRequest
)
}
/**
* 한 번만 실행
* 즉시 1회 실행 (메인화면 진입 / 새로고침 / 알람 수신 시)
*/
fun executeOnce() {
Log.d(TAG, "즉시 1회 스크래핑 요청 (executeOnce)")
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
val workRequest = OneTimeWorkRequestBuilder<HotDealPollingWorker>()
.setConstraints(constraints)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.addTag(HotDealPollingWorker.TAG)
.build()
@@ -76,22 +84,28 @@ class WorkerScheduler @Inject constructor(
}
/**
* 폴� 취소
* 폴링 취소
*/
fun cancelPolling() {
Log.d(TAG, "폴링 전체 취소")
alarmScheduler.cancelAlarm()
WorkManager.getInstance(context).cancelUniqueWork(HotDealPollingWorker.WORK_NAME)
}
/**
* 폴� 활성화 여부 확인
* 폴링 활성화 여부 확인
*/
fun isPollingActive(): Boolean {
return try {
val workInfos = WorkManager.getInstance(context)
.getWorkInfosForUniqueWork(HotDealPollingWorker.WORK_NAME)
.get()
return workInfos.any {
workInfos.any {
it.state == WorkInfo.State.RUNNING || it.state == WorkInfo.State.ENQUEUED
}
} catch (e: Exception) {
false
}
}
}
@@ -32,6 +32,7 @@ class ScraperTest {
testClien()
testRuriweb()
testCoolenjoy()
testArcaLive()
println("\n" + "#".repeat(60))
println("# 테스트 완료")
@@ -149,4 +150,32 @@ class ScraperTest {
}
)
}
suspend fun testArcaLive() {
println("\n" + "=".repeat(60))
println("【아카라이브 (arcalive)】")
println("=".repeat(60))
val scraper = ArcaLiveScraper(client)
val result = scraper.scrape("hotdeal")
result.fold(
onSuccess = { deals ->
deals.take(5).forEachIndexed { index, deal ->
println("\n[게시물 ${index + 1}]")
println(" ID: ${deal.id}")
println(" 제목: ${deal.title}")
println(" URL: ${deal.url}")
}
if (deals.isEmpty()) {
println(" ⚠️ 파싱된 게시물이 없습니다.")
} else {
println("\n ✅ 총 ${deals.size}개 파싱 성공")
}
},
onFailure = { error ->
println(" ❌ 오류: ${error.message}")
}
)
}
}
@@ -70,6 +70,17 @@ class SiteTypeTest {
assertTrue(site.boards.any { it.id == "jirum" })
}
@Test
fun `ARCALIVE should have correct boards`() {
// Given
val site = SiteType.ARCALIVE
// Then
assertEquals("아카라이브", site.displayName)
assertEquals(1, site.boards.size)
assertTrue(site.boards.any { it.id == "hotdeal" })
}
@Test
fun `all SiteTypes should have at least one board`() {
// When
+6 -7
View File
@@ -1,11 +1,10 @@
{
"version": "1.11.6",
"versionCode": 23,
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases/download/v1.11.6/app-release.apk",
"version": "0.3.5",
"versionCode": 35,
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases/download/v0.3.5/app-release.apk",
"changelog": [
"자동 업데이트 설치 권한 체크 추가",
"업데이트 리시버 메모리 누수 수정",
"업데이트 체크 에러 처리 개선",
"설정 화면 업데이트 다운로드 진행률 표시"
"사이트 및 키워드 순서 조절을 터치 드래그 앤 드롭(Drag & Drop) 방식으로 전면 업그레이드",
"앱 내 업데이트 시 다운로드 완료 후 설치 화면으로 안 넘어가는 버그 수정",
"새 버전 업데이트 안내 다이얼로그 및 진행률 프로그레스 바 연동"
]
}