18 Commits
Author SHA1 Message Date
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
17 changed files with 1952 additions and 1751 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ android {
applicationId = "com.hotdeal.alarm" applicationId = "com.hotdeal.alarm"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 23 versionCode = 24
versionName = "1.11.6" versionName = "0.2.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@@ -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"
}
}
@@ -9,7 +9,8 @@ class ScraperFactory(
private val ppomppu: PpomppuScraper, private val ppomppu: PpomppuScraper,
private val clien: ClienScraper, private val clien: ClienScraper,
private val ruriweb: RuriwebScraper, 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.CLIEN -> clien
SiteType.RURIWEB -> ruriweb SiteType.RURIWEB -> ruriweb
SiteType.COOLENJOY -> coolenjoy SiteType.COOLENJOY -> coolenjoy
SiteType.ARCALIVE -> arcaLive
} }
} }
@@ -32,6 +34,7 @@ class ScraperFactory(
"clien" -> clien "clien" -> clien
"ruriweb" -> ruriweb "ruriweb" -> ruriweb
"coolenjoy" -> coolenjoy "coolenjoy" -> coolenjoy
"arcalive" -> arcaLive
else -> null else -> null
} }
} }
@@ -40,6 +43,6 @@ class ScraperFactory(
* 모든 스크래퍼 반환 * 모든 스크래퍼 반환
*/ */
fun getAllScrapers(): List<BaseScraper> { 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) return CoolenjoyScraper(client)
} }
@Provides
@Singleton
fun provideArcaLiveScraper(client: OkHttpClient): ArcaLiveScraper {
return ArcaLiveScraper(client)
}
@Provides @Provides
@Singleton @Singleton
fun provideScraperFactory( fun provideScraperFactory(
ppomppu: PpomppuScraper, ppomppu: PpomppuScraper,
clien: ClienScraper, clien: ClienScraper,
ruriweb: RuriwebScraper, ruriweb: RuriwebScraper,
coolenjoy: CoolenjoyScraper coolenjoy: CoolenjoyScraper,
arcaLive: ArcaLiveScraper
): ScraperFactory { ): ScraperFactory {
return ScraperFactory( return ScraperFactory(
ppomppu = ppomppu, ppomppu = ppomppu,
clien = clien, clien = clien,
ruriweb = ruriweb, ruriweb = ruriweb,
coolenjoy = coolenjoy coolenjoy = coolenjoy,
arcaLive = arcaLive
) )
} }
} }
@@ -35,6 +35,12 @@ enum class SiteType(
boards = listOf( boards = listOf(
BoardInfo("jirum", "알뜰구매") BoardInfo("jirum", "알뜰구매")
) )
),
ARCALIVE(
displayName = "아카라이브",
boards = listOf(
BoardInfo("hotdeal", "핫딜")
)
); );
companion object { companion object {
@@ -2,137 +2,160 @@ package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.* import androidx.compose.animation.*
import androidx.compose.animation.core.* 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.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale 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 androidx.compose.ui.unit.dp
import com.hotdeal.alarm.ui.theme.CornerRadius
import com.hotdeal.alarm.ui.theme.Spacing import com.hotdeal.alarm.ui.theme.Spacing
/** /**
* 애니메이션이 있는 아이콘 버튼 * One UI 9 Elastic Spring Press Effect
* 클릭 시 부드럽게 눌리고 뗐을 때 탄성 있게 복원되는 고품질 인터랙션
*/ */
@Composable fun Modifier.elasticPressClickable(
fun AnimatedIconButton( interactionSource: MutableInteractionSource? = null,
onClick: () -> Unit, enabled: Boolean = true,
icon: @Composable () -> Unit, pressedScale: Float = 0.97f,
modifier: Modifier = Modifier, onClick: () -> Unit
isActivated: Boolean = false ): Modifier = composed {
) { val source = interactionSource ?: remember { MutableInteractionSource() }
val scale by animateFloatAsState( val isPressed by source.collectIsPressedAsState()
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) }
val scale by animateFloatAsState( val scale by animateFloatAsState(
targetValue = if (isPressed) 0.95f else 1f, targetValue = if (isPressed && enabled) pressedScale else 1f,
animationSpec = spring( animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy, dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessHigh stiffness = Spring.StiffnessMedium
), ),
label = "card_scale" label = "elastic_press_scale"
) )
Card( this
modifier = modifier .scale(scale)
.scale(scale), .clickable(
onClick = { interactionSource = source,
isPressed = true indication = null, // 불필요한 기본 리플 번짐을 끄고 Elastic Scale로 최고급 감각 제공
onClick() enabled = enabled,
} onClick = onClick
) { )
content()
}
LaunchedEffect(isPressed) {
if (isPressed) {
kotlinx.coroutines.delay(100)
isPressed = false
}
}
} }
/** /**
* 로딩 오버레이 * One UI 9 Squircle Container Card with Ambient Border
*/ */
@Composable @Composable
fun LoadingOverlay( fun SquircleCard(
isLoading: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
content: @Composable () -> Unit shape: Shape = CornerRadius.shapeSquircle,
) { containerColor: Color = MaterialTheme.colorScheme.surface,
Box(modifier = modifier) { borderColor: Color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f),
content() borderWidth: Dp = 0.8.dp,
content: @Composable BoxScope.() -> Unit
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize()
) { ) {
Box( Box(
modifier = Modifier modifier = modifier
.fillMaxSize() .clip(shape)
.padding(Spacing.lg), .background(containerColor)
contentAlignment = Alignment.Center .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
)
}
}
} }
@@ -3,10 +3,8 @@ package com.hotdeal.alarm.presentation.components
import androidx.compose.animation.core.* import androidx.compose.animation.core.*
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.outlined.*
@@ -16,20 +14,20 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.scale 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.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.hotdeal.alarm.domain.model.HotDeal 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 import com.hotdeal.alarm.util.ShareHelper
@OptIn(ExperimentalMaterial3Api::class) /**
* One UI 9 & Material 3 Expressive Deal Item Card
* Text Spotlight 가독성과 Squircle 앰비언트 깊이감을 적용한 프리미엄 핫딜 카드
*/
@Composable @Composable
fun DealItem( fun DealItem(
deal: HotDeal, deal: HotDeal,
@@ -38,124 +36,131 @@ fun DealItem(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
val context = LocalContext.current 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 siteColor = getSiteColor(deal.siteType)
// 키워드 매칭 배경 - 옅은 붉은색 단색 배경 // 즐겨찾기 하트 펄스 애니메이션
val cardColors = if (deal.isKeywordMatch) { val favoriteScale by animateFloatAsState(
CardDefaults.elevatedCardColors( targetValue = if (deal.isFavorite) 1.25f else 1f,
containerColor = Color(0xFFFCE8E8) // 옅은 붉은색 animationSpec = spring(
) dampingRatio = Spring.DampingRatioMediumBouncy,
} else { stiffness = Spring.StiffnessMedium
CardDefaults.elevatedCardColors( ),
containerColor = MaterialTheme.colorScheme.surface label = "favorite_pulse"
) )
// 제목에서 가격 정보 및 쇼핑몰 태그 추출 (Text Spotlight용)
val parsedInfo = remember(deal.title) {
parseDealTitle(deal.title)
} }
Box(modifier = modifier) { // 카드 테두리 및 배경 톤 설정 (One UI 9 Squircle Depth)
// 키워드 매칭 게시물도 일반 카드와 동일한 형태로 표시 (색상만 다름) val (cardBorderColor, cardBorderWidth) = when {
deal.isKeywordMatch -> KeywordGold to 1.5.dp
deal.isPopular -> HotDealFlameColor.copy(alpha = 0.8f) to 1.2.dp
else -> MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f) to 0.8.dp
}
ElevatedCard( val cardBgColor = when {
modifier = Modifier.fillMaxWidth(), deal.isKeywordMatch -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
shape = RoundedCornerShape(20.dp), else -> MaterialTheme.colorScheme.surface
colors = cardColors, }
elevation = CardDefaults.elevatedCardElevation(
defaultElevation = 2.dp SquircleCard(
), shape = CornerRadius.shapeSquircle,
onClick = onClick containerColor = cardBgColor,
borderColor = cardBorderColor,
borderWidth = cardBorderWidth,
modifier = modifier
.fillMaxWidth()
.elasticPressClickable(onClick = onClick)
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(horizontal = 16.dp, vertical = 14.dp)
) { ) {
// 상단: 사이트 뱃지 + 게시판 + 액션 // ============================================
// 1. 상단 메타 바: 사이트 뱃지 + 게시판 + 특수 뱃지 + 액션
// ============================================
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// 사이트 뱃지 - 개선된 디자인 // 사이트 캡슐 뱃지
Surface( Surface(
shape = RoundedCornerShape(12.dp), shape = CornerRadius.shapeSmall,
color = siteColor.copy(alpha = 0.12f), color = siteColor.copy(alpha = 0.12f),
modifier = Modifier.height(28.dp) modifier = Modifier.height(26.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(5.dp),
modifier = Modifier.padding(horizontal = 10.dp) modifier = Modifier.padding(horizontal = 8.dp)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(8.dp) .size(6.dp)
.background(siteColor, CircleShape) .background(siteColor, CircleShape)
) )
Text( Text(
text = deal.siteType?.displayName ?: deal.siteName, text = deal.siteType?.displayName ?: deal.siteName,
style = MaterialTheme.typography.labelMedium.copy( style = SpotlightTypography.badge,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp
),
color = siteColor color = siteColor
) )
} }
} }
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(6.dp))
// 게시판 이름 - 더 세련된 스타일 // 게시판 라벨
Surface(
shape = RoundedCornerShape(10.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.height(24.dp)
) {
Text( Text(
text = deal.boardDisplayName, text = deal.boardDisplayName,
style = MaterialTheme.typography.labelSmall.copy( style = MaterialTheme.typography.labelSmall,
fontSize = 11.sp, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
fontWeight = FontWeight.Medium maxLines = 1,
), overflow = TextOverflow.Ellipsis
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f),
modifier = Modifier.padding(horizontal = 10.dp, vertical = 5.dp)
) )
// 쇼핑몰 태그 (추출된 경우)
if (parsedInfo.storeTag.isNotEmpty()) {
Spacer(modifier = Modifier.width(6.dp))
Surface(
shape = CornerRadius.shapeMicro,
color = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.7f),
modifier = Modifier.height(20.dp)
) {
Text(
text = parsedInfo.storeTag,
style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.5.sp),
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
} }
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// 키워드 매칭 - 더 눈에 띄는 디자인 // 키워드 매칭 (One UI 9 Amber Gold)
if (deal.isKeywordMatch) { if (deal.isKeywordMatch) {
Surface( Surface(
shape = RoundedCornerShape(10.dp), shape = CornerRadius.shapeSmall,
color = Color(0xFFE53935), color = KeywordGold,
modifier = Modifier.height(24.dp) modifier = Modifier.height(24.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 10.dp) modifier = Modifier.padding(horizontal = 7.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Star, imageVector = Icons.Filled.Star,
contentDescription = null, contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(12.dp) modifier = Modifier.size(11.dp)
) )
Text( Text(
text = "내 키워드", text = "내 키워드",
style = MaterialTheme.typography.labelSmall.copy( style = SpotlightTypography.badge.copy(fontSize = 11.sp),
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
),
color = Color.White color = Color.White
) )
} }
@@ -163,139 +168,162 @@ fun DealItem(
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
} }
// 인기/핫 배지 - 인기 게시물 // 인기 핫딜 뱃지 (One UI 9 Flame Orange)
if (deal.isPopular) { if (deal.isPopular) {
Surface( Surface(
shape = RoundedCornerShape(10.dp), shape = CornerRadius.shapeSmall,
color = Color(0xFFFF6B35), // 오렌지색 color = HotDealFlameColor,
modifier = Modifier.height(24.dp) modifier = Modifier.height(24.dp)
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(3.dp),
modifier = Modifier.padding(horizontal = 10.dp) modifier = Modifier.padding(horizontal = 7.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Whatshot, imageVector = Icons.Filled.Whatshot,
contentDescription = null, contentDescription = null,
tint = Color.White, tint = Color.White,
modifier = Modifier.size(12.dp) modifier = Modifier.size(11.dp)
) )
Text( Text(
text = "인기", text = "HOT",
style = MaterialTheme.typography.labelSmall.copy( style = SpotlightTypography.badge.copy(fontSize = 11.sp),
fontWeight = FontWeight.SemiBold,
fontSize = 11.sp
),
color = Color.White color = Color.White
) )
} }
} }
} }
}
// 액션 버튼들 - 더 작고 세련된 스타일 Spacer(modifier = Modifier.height(10.dp))
// ============================================
// 2. 제목 (Clean Spotlight Typography)
// ============================================
Text(
text = parsedInfo.cleanTitle,
style = MaterialTheme.typography.titleMedium.copy(
fontWeight = if (deal.isKeywordMatch) FontWeight.Bold else FontWeight.SemiBold,
lineHeight = 22.sp
),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(12.dp))
// ============================================
// 3. 하단 바: 가격(Text Spotlight) + 시간 + 액션 버튼
// ============================================
Row( Row(
horizontalArrangement = Arrangement.spacedBy(0.dp) modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
// 가격이 추출되었을 경우 볼드 강조, 없을 경우 기본 시간 강조
if (parsedInfo.priceText.isNotEmpty()) {
Text(
text = parsedInfo.priceText,
style = SpotlightTypography.priceLarge,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.width(8.dp))
}
// 작성 상대 시간
Text(
text = formatRelativeTime(deal.createdAt),
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.weight(1f))
// 액션 아이콘들 (공유 & 즐겨찾기)
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) { ) {
// 공유 버튼
IconButton( IconButton(
onClick = { ShareHelper.shareDeal(context, deal) }, onClick = { ShareHelper.shareDeal(context, deal) },
modifier = Modifier.size(36.dp) modifier = Modifier.size(32.dp)
) { ) {
Icon( Icon(
imageVector = Icons.Outlined.Share, imageVector = Icons.Outlined.Share,
contentDescription = "공유", contentDescription = "공유",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
modifier = Modifier.size(18.dp) modifier = Modifier.size(17.dp)
) )
} }
// 즐겨찾기 버튼
IconButton( IconButton(
onClick = { onFavoriteToggle(deal.id) }, onClick = { onFavoriteToggle(deal.id) },
modifier = Modifier modifier = Modifier
.size(36.dp) .size(32.dp)
.scale(favoriteScale) .scale(favoriteScale)
) { ) {
Icon( Icon(
imageVector = if (deal.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder, imageVector = if (deal.isFavorite) Icons.Filled.Favorite else Icons.Outlined.FavoriteBorder,
contentDescription = if (deal.isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가", contentDescription = if (deal.isFavorite) "즐겨찾기 제거" else "즐겨찾기 추가",
tint = if (deal.isFavorite) tint = if (deal.isFavorite) FavoriteColor else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.65f),
MaterialTheme.colorScheme.error
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(18.dp) modifier = Modifier.size(18.dp)
) )
} }
} }
} }
}
}
}
Spacer(modifier = Modifier.height(12.dp)) /**
* 핫딜 제목에서 상점 태그 가격을 분리하는 헬퍼
// 제목 - 더 크고 읽기 쉬운 스타일 */
Text( private data class ParsedDealInfo(
text = deal.title, val storeTag: String,
style = if (deal.isKeywordMatch) { val priceText: String,
MaterialTheme.typography.bodyLarge.copy( val cleanTitle: String
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)) private fun parseDealTitle(rawTitle: String): ParsedDealInfo {
var title = rawTitle.trim()
var store = ""
var price = ""
// 하단: 시간 + 화살표 // 상점 태그 추출: [스토어]
Row( if (title.startsWith("[")) {
modifier = Modifier.fillMaxWidth(), val closeIdx = title.indexOf("]")
verticalAlignment = Alignment.CenterVertically if (closeIdx in 1..25) {
) { store = title.substring(1, closeIdx).trim()
// 시간 - 아이콘 없이 깔끔하게 title = title.substring(closeIdx + 1).trim()
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)) // 끝부분 가격 추출: (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(
Surface( storeTag = store,
shape = CircleShape, priceText = price,
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.08f), cleanTitle = if (title.isEmpty()) rawTitle else title
modifier = Modifier.size(28.dp)
) {
Box(
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Outlined.ArrowForward,
contentDescription = "이동",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp)
) )
} }
}
}
}
}
}
}
private fun formatTime(timestamp: Long): String { /**
* 상대 시간 포맷팅
*/
private fun formatRelativeTime(timestamp: Long): String {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val diff = now - timestamp val diff = now - timestamp
@@ -12,7 +12,6 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.* import androidx.compose.material.icons.filled.*
import androidx.compose.material.icons.outlined.* import androidx.compose.material.icons.outlined.*
@@ -22,22 +21,26 @@ import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import androidx.compose.ui.draw.scale
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.hotdeal.alarm.domain.model.SiteType import com.hotdeal.alarm.domain.model.SiteType
import com.hotdeal.alarm.presentation.components.* import com.hotdeal.alarm.presentation.components.*
import com.hotdeal.alarm.presentation.main.MainUiState import com.hotdeal.alarm.presentation.main.MainUiState
import com.hotdeal.alarm.presentation.main.MainViewModel import com.hotdeal.alarm.presentation.main.MainViewModel
import com.hotdeal.alarm.ui.theme.getSiteColor import com.hotdeal.alarm.ui.theme.*
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/**
* One UI 9 & Material 3 Expressive Deal Feed Screen
* 실시간 Now 브리핑, 일체형 검색 & Sticky Pill 필터링을 지원하는 메인 핫딜 피드
*/
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun DealListScreen( fun DealListScreen(
@@ -47,20 +50,18 @@ fun DealListScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current val context = LocalContext.current
// Pull to Refresh 상태 - 민감도 대폭 향상 // Pull to Refresh (초고감도 설정)
val pullToRefreshState = rememberPullToRefreshState( val pullToRefreshState = rememberPullToRefreshState(
positionalThreshold = 40.dp // 기본값의 1/3로 설정하여 매우 쉽게 새로고침 positionalThreshold = 40.dp
) )
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
// List state for scroll detection
val listState = rememberLazyListState() val listState = rememberLazyListState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val showScrollToTop by remember { val showScrollToTop by remember {
derivedStateOf { listState.firstVisibleItemIndex > 3 } derivedStateOf { listState.firstVisibleItemIndex > 3 }
} }
// 새로고침 완료 감지
LaunchedEffect(uiState) { LaunchedEffect(uiState) {
if (uiState !is MainUiState.Loading) { if (uiState !is MainUiState.Loading) {
isRefreshing = false isRefreshing = false
@@ -68,79 +69,69 @@ fun DealListScreen(
} }
} }
// 필터 상태
var searchText by remember { mutableStateOf("") } var searchText by remember { mutableStateOf("") }
var selectedSiteFilter by remember { mutableStateOf<SiteType?>(null) } var selectedSiteFilter by remember { mutableStateOf<SiteType?>(null) }
var showFavoritesOnly by remember { mutableStateOf(false) } var showFavoritesOnly by remember { mutableStateOf(false) }
var showPopularOnly by remember { mutableStateOf(false) }
var showKeywordMatchOnly 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
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { title = {
Row(verticalAlignment = Alignment.CenterVertically) { Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
// One UI 9 App Logo Icon
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.background(MaterialTheme.colorScheme.primary, CircleShape), .clip(CornerRadius.shapeSmall)
.background(MaterialTheme.colorScheme.primary),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Notifications, imageVector = Icons.Filled.LocalFireDepartment,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary, tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
} }
Spacer(modifier = Modifier.width(12.dp))
Column {
Text( Text(
text = "핫딜 알람", text = "핫딜 알람",
style = MaterialTheme.typography.titleLarge, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold fontWeight = FontWeight.Bold,
letterSpacing = (-0.3).sp
) )
} }
}
}, },
actions = { actions = {
BadgedBox( // 실시간 상태 요약 캡슐
badge = { if (uiState is MainUiState.Success) {
if (selectedSiteFilter != null || showFavoritesOnly || showKeywordMatchOnly) { val activeSitesCount = (uiState as MainUiState.Success).siteConfigs.count { it.isEnabled }
Badge( NowStatusPill(
containerColor = MaterialTheme.colorScheme.primary, text = if (activeSitesCount > 0) "${activeSitesCount}개 사이트 감시 중" else "모니터링 대기",
contentColor = MaterialTheme.colorScheme.onPrimary isLive = activeSitesCount > 0,
accentColor = MaterialTheme.colorScheme.primary
) )
Spacer(modifier = Modifier.width(4.dp))
} }
}
) {
IconButton(onClick = { showFilterMenu = !showFilterMenu }) {
Icon(
imageVector = if (showFilterMenu) Icons.Filled.FilterList else Icons.Outlined.FilterList,
contentDescription = "필터"
)
}
}
IconButton(onClick = { viewModel.refresh() }) { IconButton(onClick = { viewModel.refresh() }) {
Icon( Icon(
imageVector = Icons.Outlined.Refresh, imageVector = Icons.Outlined.Refresh,
contentDescription = "새로고침" contentDescription = "새로고침",
) tint = MaterialTheme.colorScheme.onSurfaceVariant
}
IconButton(onClick = { onNavigateToSettings() }) {
Icon(
imageVector = Icons.Outlined.Settings,
contentDescription = "설정"
) )
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.background,
scrolledContainerColor = MaterialTheme.colorScheme.surface scrolledContainerColor = MaterialTheme.colorScheme.surface
), ),
windowInsets = WindowInsets(0, 0, 0, 0) windowInsets = WindowInsets(0, 0, 0, 0)
@@ -181,111 +172,30 @@ fun DealListScreen(
.padding(paddingValues) .padding(paddingValues)
.consumeWindowInsets(paddingValues) .consumeWindowInsets(paddingValues)
) { ) {
// 필터 메뉴 // ============================================
AnimatedVisibility( // 1. One UI 9 Seamless Search Field
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))
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
EnhancedFilterChip(
selected = selectedSiteFilter == null,
onClick = { selectedSiteFilter = null },
label = "전체",
color = MaterialTheme.colorScheme.primary
)
SiteType.entries.forEach { siteType ->
val color = getSiteColor(siteType)
EnhancedFilterChip(
selected = selectedSiteFilter == siteType,
onClick = {
selectedSiteFilter = if (selectedSiteFilter == siteType) null else siteType
},
label = siteType.displayName,
color = color
)
}
}
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
)
}
}
}
}
// 검색창
OutlinedTextField( OutlinedTextField(
value = searchText, value = searchText,
onValueChange = { searchText = it }, onValueChange = { searchText = it },
placeholder = { placeholder = {
Text( Text(
text = "제목으로 검색...", text = "키워드, 브랜드, 상품명 검색...",
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.55f)
) )
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 6.dp),
singleLine = true, singleLine = true,
shape = RoundedCornerShape(16.dp), shape = CornerRadius.shapeNormal,
leadingIcon = { leadingIcon = {
Icon( Icon(
imageVector = Icons.Outlined.Search, imageVector = Icons.Outlined.Search,
contentDescription = "검색", contentDescription = "검색",
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.size(20.dp)
) )
}, },
trailingIcon = { trailingIcon = {
@@ -294,31 +204,127 @@ fun DealListScreen(
Icon( Icon(
imageVector = Icons.Filled.Close, imageVector = Icons.Filled.Close,
contentDescription = "지우기", contentDescription = "지우기",
tint = MaterialTheme.colorScheme.onSurfaceVariant tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp)
) )
} }
} }
}, },
colors = OutlinedTextFieldDefaults.colors( colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surface,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
focusedBorderColor = MaterialTheme.colorScheme.primary, focusedBorderColor = MaterialTheme.colorScheme.primary,
unfocusedBorderColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f) unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
) )
) )
// 딜 리스트 // ============================================
// 2. One UI 9 Sticky Horizontal Pill Filter Bar
// ============================================
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp)
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Spacer(modifier = Modifier.width(8.dp))
// "전체" 필터 칩
FilterPillChip(
selected = selectedSiteFilter == null && !showFavoritesOnly && !showPopularOnly && !showKeywordMatchOnly,
onClick = {
selectedSiteFilter = null
showFavoritesOnly = false
showPopularOnly = false
showKeywordMatchOnly = false
},
label = "전체",
accentColor = MaterialTheme.colorScheme.primary
)
// "🔥 HOT 인기" 필터 칩
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
)
// 사이트별 개별 필터 칩들 (뽐뿌, 클리앙, 루리웹, 쿨엔조이, 아카라이브)
SiteType.entries.forEach { siteType ->
val siteColor = getSiteColor(siteType)
FilterPillChip(
selected = selectedSiteFilter == siteType,
onClick = {
selectedSiteFilter = if (selectedSiteFilter == siteType) null else siteType
},
label = siteType.displayName,
accentColor = siteColor
)
}
Spacer(modifier = Modifier.width(8.dp))
}
// ============================================
// 3. 핫딜 목록 본문 (List Content)
// ============================================
when (val state = uiState) { when (val state = uiState) {
is MainUiState.Loading -> { is MainUiState.Loading -> {
DealListSkeleton(count = 5) DealListSkeleton(count = 6)
} }
is MainUiState.Success -> { 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 -> state.deals.filter { deal ->
val matchesSearch = searchText.isBlank() || deal.title.contains(searchText, ignoreCase = true) val matchesSearch = searchText.isBlank() || deal.title.contains(searchText, ignoreCase = true)
val matchesSite = selectedSiteFilter == null || deal.siteType == selectedSiteFilter val matchesSite = selectedSiteFilter == null || deal.siteType == selectedSiteFilter
val matchesFavorite = !showFavoritesOnly || deal.isFavorite val matchesFavorite = !showFavoritesOnly || deal.isFavorite
val matchesPopular = !showPopularOnly || deal.isPopular
val matchesKeyword = !showKeywordMatchOnly || deal.isKeywordMatch val matchesKeyword = !showKeywordMatchOnly || deal.isKeywordMatch
matchesSearch && matchesSite && matchesFavorite && matchesKeyword matchesSearch && matchesSite && matchesFavorite && matchesPopular && matchesKeyword
} }
} }
@@ -326,74 +332,55 @@ fun DealListScreen(
if (state.deals.isEmpty()) { if (state.deals.isEmpty()) {
NoDealsState(onRefresh = { viewModel.refresh() }) NoDealsState(onRefresh = { viewModel.refresh() })
} else { } else {
val selectedFilter = selectedSiteFilter
val message = when { val message = when {
showKeywordMatchOnly -> "키워드 매칭된 핫딜이 없습니다" showKeywordMatchOnly -> "키워드 매칭된 핫딜이 없습니다"
showPopularOnly -> "인기 핫딜이 없습니다"
showFavoritesOnly -> "즐겨찾기한 핫딜이 없습니다" showFavoritesOnly -> "즐겨찾기한 핫딜이 없습니다"
selectedFilter != null -> "${selectedFilter.displayName}의 핫딜이 없습니다" selectedSiteFilter != null -> "${selectedSiteFilter?.displayName}의 핫딜이 없습니다"
searchText.isNotBlank() -> "'$searchText'에 대한 검색 결과가 없습니다" searchText.isNotBlank() -> "'$searchText'에 대한 검색 결과가 없습니다"
else -> "표시할 핫딜이 없습니다" else -> "표시할 핫딜이 없습니다"
} }
EmptyState( EmptyState(
title = "결과가 없습니다", title = "핫딜이 없습니다",
message = message, message = message,
icon = Icons.Outlined.Search icon = Icons.Outlined.Search
) )
} }
} else { } else {
Surface( // 리스트 상단 카운터 바
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
) {
Row( Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 18.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Icon( Text(
imageVector = Icons.Outlined.Inventory2, text = "${filteredDeals.size}개의 핫딜",
contentDescription = null, style = MaterialTheme.typography.labelMedium,
tint = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold,
modifier = Modifier.size(16.dp) color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f)
) )
if (selectedSiteFilter != null) {
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = "${filteredDeals.size}개의 핫딜", text = "${selectedSiteFilter?.displayName}",
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Medium, color = getSiteColor(selectedSiteFilter)
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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = listState, state = listState,
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 6.dp),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(10.dp)
) { ) {
items( items(
items = filteredDeals, items = filteredDeals,
key = { it.id } key = { it.id }
) { deal -> ) { deal ->
AnimatedVisibility(
visible = true,
enter = fadeIn(animationSpec = tween(300)) +
slideInVertically(
animationSpec = tween(300),
initialOffsetY = { it / 8 }
),
exit = fadeOut(animationSpec = tween(200))
) {
DealItem( DealItem(
deal = deal, deal = deal,
onClick = { onClick = {
@@ -405,11 +392,9 @@ fun DealListScreen(
} }
) )
} }
}
// EdgeToEdge: 하단 네비게이션 바 공간 확보
item { item {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(24.dp))
} }
} }
} }
@@ -424,7 +409,7 @@ fun DealListScreen(
} }
} }
// Pull to Refresh 인디케이터 - 절대 위치로 배치하여 간격 벌어짐 방지 // One UI 9 Pull to Refresh 인디케이터
val progress = pullToRefreshState.progress val progress = pullToRefreshState.progress
val showIndicator = pullToRefreshState.isRefreshing || progress > 0 val showIndicator = pullToRefreshState.isRefreshing || progress > 0
@@ -433,14 +418,13 @@ fun DealListScreen(
state = pullToRefreshState, state = pullToRefreshState,
modifier = Modifier modifier = Modifier
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(top = 100.dp) .padding(top = 90.dp)
.zIndex(999f), .zIndex(999f),
containerColor = MaterialTheme.colorScheme.primaryContainer, containerColor = MaterialTheme.colorScheme.surfaceVariant,
contentColor = MaterialTheme.colorScheme.onPrimaryContainer contentColor = MaterialTheme.colorScheme.primary
) )
} }
// 새로고침 트리거
LaunchedEffect(pullToRefreshState.isRefreshing) { LaunchedEffect(pullToRefreshState.isRefreshing) {
if (pullToRefreshState.isRefreshing) { if (pullToRefreshState.isRefreshing) {
isRefreshing = true isRefreshing = true
@@ -451,46 +435,52 @@ fun DealListScreen(
} }
} }
/**
* One UI 9 Capsule Pill Filter Chip
*/
@Composable @Composable
private fun EnhancedFilterChip( private fun FilterPillChip(
selected: Boolean, selected: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
label: String, label: String,
color: Color accentColor: Color
) { ) {
val scale by animateFloatAsState( val bgColor = if (selected) {
targetValue = if (selected) 1.05f else 1f, accentColor.copy(alpha = 0.16f)
animationSpec = spring(stiffness = Spring.StiffnessLow), } else {
label = "chip_scale" MaterialTheme.colorScheme.surface
) }
val textColor = if (selected) {
accentColor
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
val borderColor = if (selected) {
accentColor.copy(alpha = 0.6f)
} else {
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)
}
Surface( Surface(
onClick = onClick, shape = CornerRadius.shapePill,
color = bgColor,
border = androidx.compose.foundation.BorderStroke(0.8.dp, borderColor),
modifier = Modifier modifier = Modifier
.height(36.dp) .height(34.dp)
.scale(scale), .elasticPressClickable(onClick = onClick)
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
) { ) {
Row( Box(
modifier = Modifier.padding(horizontal = 16.dp), contentAlignment = Alignment.Center,
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp)
horizontalArrangement = Arrangement.spacedBy(6.dp)
) { ) {
if (selected) {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
}
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelMedium.copy(
fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium
color = if (selected) Color.White else color ),
color = textColor
) )
} }
} }
@@ -5,30 +5,39 @@ import android.content.pm.PackageManager
import android.os.Build import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.animation.*
import androidx.compose.animation.core.*
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.pager.rememberPagerState
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.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier 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.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.core.content.ContextCompat
import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.PermissionDialog
import com.hotdeal.alarm.presentation.components.elasticPressClickable
import com.hotdeal.alarm.presentation.deallist.DealListScreen import com.hotdeal.alarm.presentation.deallist.DealListScreen
import com.hotdeal.alarm.presentation.settings.SettingsScreen import com.hotdeal.alarm.presentation.settings.SettingsScreen
import com.hotdeal.alarm.ui.theme.CornerRadius
import kotlinx.coroutines.launch
@OptIn(ExperimentalFoundationApi::class) /**
* One UI 9 Fluid Navigation Framework
* 조작성을 극대화한 하단 캡슐 내비게이션 화면 전환
*/
@Composable @Composable
fun MainScreen( fun MainScreen(viewModel: MainViewModel) {
viewModel: MainViewModel,
navController: NavHostController = rememberNavController()
) {
val context = LocalContext.current val context = LocalContext.current
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@@ -53,37 +62,92 @@ fun MainScreen(
} }
} }
val pagerState = rememberPagerState( val pagerState = rememberPagerState(initialPage = 0, pageCount = { 2 })
initialPage = 0,
pageCount = { 2 }
)
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
var currentPage by remember { mutableStateOf(0) }
LaunchedEffect(pagerState) { Scaffold(
snapshotFlow { pagerState.currentPage }.collect { page -> bottomBar = {
currentPage = page // One UI 9 Fluid Bottom Navigation Bar
Surface(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding(),
color = MaterialTheme.colorScheme.surface,
tonalElevation = 3.dp,
border = androidx.compose.foundation.BorderStroke(
0.8.dp,
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.25f)
)
) {
NavigationBar(
containerColor = Color.Transparent,
tonalElevation = 0.dp,
modifier = Modifier.height(64.dp)
) {
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
NavigationBarItem(
selected = isSelected,
onClick = {
coroutineScope.launch {
pagerState.animateScrollToPage(pageIndex)
}
},
icon = {
Icon(
imageVector = if (isSelected) selectedIcon else unselectedIcon,
contentDescription = label,
modifier = Modifier.size(24.dp)
)
},
label = {
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Medium,
fontSize = 11.sp
)
)
},
colors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.primary,
selectedTextColor = MaterialTheme.colorScheme.primary,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
indicatorColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.6f)
)
)
} }
} }
}
Column( },
modifier = Modifier.fillMaxSize() contentWindowInsets = WindowInsets(0, 0, 0, 0)
) { paddingValues ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
) { ) {
HorizontalPager( HorizontalPager(
state = pagerState, state = pagerState,
modifier = Modifier.weight(1f) modifier = Modifier.fillMaxSize(),
beyondBoundsPageCount = 1
) { page -> ) { page ->
when (page) { when (page) {
0 -> { 0 -> DealListScreen(
DealListScreen(
viewModel = viewModel, viewModel = viewModel,
onNavigateToSettings = { onNavigateToSettings = {
coroutineScope.launch { pagerState.animateScrollToPage(1) } coroutineScope.launch { pagerState.animateScrollToPage(1) }
} }
) )
} 1 -> SettingsScreen(viewModel = viewModel)
1 -> {
SettingsScreen(viewModel = viewModel)
} }
} }
} }
@@ -98,8 +162,3 @@ fun MainScreen(
) )
} }
} }
sealed class Screen(val route: String) {
object DealList : Screen("deal_list")
object Settings : Screen("settings")
}
@@ -48,8 +48,9 @@ class MainViewModel @Inject constructor(
private suspend fun initializeDefaultSiteConfigs() { private suspend fun initializeDefaultSiteConfigs() {
val existingConfigs = siteConfigDao.getAllConfigs() val existingConfigs = siteConfigDao.getAllConfigs()
if (existingConfigs.isEmpty()) { val existingKeys = existingConfigs.map { it.siteBoardKey }.toSet()
val defaultConfigs = SiteType.entries.flatMap { site ->
val allDefaultConfigs = SiteType.entries.flatMap { site ->
site.boards.map { board -> site.boards.map { board ->
SiteConfigEntity( SiteConfigEntity(
siteBoardKey = "${site.name}_${board.id}", siteBoardKey = "${site.name}_${board.id}",
@@ -60,7 +61,10 @@ class MainViewModel @Inject constructor(
) )
} }
} }
siteConfigDao.insertConfigs(defaultConfigs)
val missingConfigs = allDefaultConfigs.filter { it.siteBoardKey !in existingKeys }
if (missingConfigs.isNotEmpty()) {
siteConfigDao.insertConfigs(missingConfigs)
} }
} }
File diff suppressed because it is too large Load Diff
@@ -1,34 +1,67 @@
package com.hotdeal.alarm.ui.theme package com.hotdeal.alarm.ui.theme
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
/**
* One UI 9 & Material 3 Expressive 간격 시스템
*/
object Spacing { object Spacing {
val xxs = 2.dp
val xs = 4.dp val xs = 4.dp
val sm = 8.dp val sm = 8.dp
val md = 16.dp val md = 12.dp
val lg = 24.dp val normal = 16.dp
val xl = 32.dp val lg = 20.dp
val xxl = 48.dp val xl = 24.dp
val xxl = 32.dp
val xxxl = 48.dp
} }
/**
* One UI 9 Squircle 고곡률 라운딩 시스템
*/
object CornerRadius { object CornerRadius {
val small = 8.dp val micro = 6.dp
val medium = 12.dp val small = 10.dp
val large = 16.dp val medium = 14.dp
val extraLarge = 24.dp val normal = 18.dp
val full = 9999.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 { object Elevation {
val none = 0.dp val none = 0.dp
val small = 2.dp val ambient = 1.dp
val subtle = 2.dp
val medium = 4.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 { object IconSize {
val mini = 12.dp
val small = 16.dp val small = 16.dp
val medium = 24.dp val medium = 20.dp
val large = 32.dp val normal = 24.dp
val extraLarge = 48.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.material3.lightColorScheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect import androidx.compose.runtime.SideEffect
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -18,112 +19,131 @@ import androidx.core.view.WindowCompat
import com.hotdeal.alarm.domain.model.SiteType 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 // Light Theme - Clean Frost & Vibrant Indigo
private val LightPrimary = Color(0xFF0061A4) private val LightPrimary = Color(0xFF2563EB) // Electric Indigo
private val LightOnPrimary = Color(0xFFFFFFFF) private val LightOnPrimary = Color(0xFFFFFFFF)
private val LightPrimaryContainer = Color(0xFFD1E4FF) private val LightPrimaryContainer = Color(0xFFDBEAFE)
private val LightOnPrimaryContainer = Color(0xFF001D36) private val LightOnPrimaryContainer = Color(0xFF1E3A8A)
private val LightSecondary = Color(0xFF535F70) private val LightSecondary = Color(0xFF475569) // Slate
private val LightOnSecondary = Color(0xFFFFFFFF) private val LightOnSecondary = Color(0xFFFFFFFF)
private val LightSecondaryContainer = Color(0xFFD7E3F8) private val LightSecondaryContainer = Color(0xFFF1F5F9)
private val LightOnSecondaryContainer = Color(0xFF101C2B) private val LightOnSecondaryContainer = Color(0xFF0F172A)
private val LightTertiary = Color(0xFF6B5778) private val LightTertiary = Color(0xFF0D9488) // Modern Teal
private val LightOnTertiary = Color(0xFFFFFFFF) private val LightOnTertiary = Color(0xFFFFFFFF)
private val LightTertiaryContainer = Color(0xFFF2DAFF) private val LightTertiaryContainer = Color(0xFFCCFBF1)
private val LightOnTertiaryContainer = Color(0xFF251431) private val LightOnTertiaryContainer = Color(0xFF115E59)
private val LightBackground = Color(0xFFFDFCFF) private val LightBackground = Color(0xFFF8FAFC) // Clean Ultra Frost
private val LightOnBackground = Color(0xFF1A1C1E) private val LightOnBackground = Color(0xFF0F172A)
private val LightSurface = Color(0xFFFDFCFF) private val LightSurface = Color(0xFFFFFFFF)
private val LightOnSurface = Color(0xFF1A1C1E) private val LightOnSurface = Color(0xFF0F172A)
private val LightSurfaceVariant = Color(0xFFDFE2EB) private val LightSurfaceVariant = Color(0xFFF1F5F9)
private val LightOnSurfaceVariant = Color(0xFF43474E) private val LightOnSurfaceVariant = Color(0xFF475569)
private val LightError = Color(0xFFBA1A1A) private val LightError = Color(0xFFDC2626)
private val LightOnError = Color(0xFFFFFFFF) private val LightOnError = Color(0xFFFFFFFF)
private val LightErrorContainer = Color(0xFFFFDAD6) private val LightErrorContainer = Color(0xFFFEE2E2)
private val LightOnErrorContainer = Color(0xFF410002) private val LightOnErrorContainer = Color(0xFF991B1B)
private val LightOutline = Color(0xFF73777F) private val LightOutline = Color(0xFF94A3B8)
private val LightOutlineVariant = Color(0xFFC3C6CF) private val LightOutlineVariant = Color(0xFFE2E8F0)
private val LightInverseSurface = Color(0xFF2F3033) private val LightInverseSurface = Color(0xFF0F172A)
private val LightInverseOnSurface = Color(0xFFF1F0F4) private val LightInverseOnSurface = Color(0xFFF8FAFC)
private val LightInversePrimary = Color(0xFF9ECAFF) private val LightInversePrimary = Color(0xFF60A5FA)
private val LightSurfaceTint = Color(0xFF0061A4) private val LightSurfaceTint = Color(0xFF2563EB)
// Dark Theme - Premium Palette // Dark Theme - Deep OLED Slate (One UI 9)
private val DarkPrimary = Color(0xFF9ECAFF) private val DarkPrimary = Color(0xFF60A5FA) // Luminous Indigo
private val DarkOnPrimary = Color(0xFF003258) private val DarkOnPrimary = Color(0xFF0F172A)
private val DarkPrimaryContainer = Color(0xFF00497D) private val DarkPrimaryContainer = Color(0xFF1E3A8A)
private val DarkOnPrimaryContainer = Color(0xFFD1E4FF) private val DarkOnPrimaryContainer = Color(0xFFDBEAFE)
private val DarkSecondary = Color(0xFFBBC7DB) private val DarkSecondary = Color(0xFF94A3B8)
private val DarkOnSecondary = Color(0xFF253140) private val DarkOnSecondary = Color(0xFF0F172A)
private val DarkSecondaryContainer = Color(0xFF3B4858) private val DarkSecondaryContainer = Color(0xFF1E293B)
private val DarkOnSecondaryContainer = Color(0xFFD7E3F8) private val DarkOnSecondaryContainer = Color(0xFFF1F5F9)
private val DarkTertiary = Color(0xFFD6BEE4) private val DarkTertiary = Color(0xFF2DD4BF)
private val DarkOnTertiary = Color(0xFF3B2948) private val DarkOnTertiary = Color(0xFF042F2E)
private val DarkTertiaryContainer = Color(0xFF523F5F) private val DarkTertiaryContainer = Color(0xFF115E59)
private val DarkOnTertiaryContainer = Color(0xFFF2DAFF) private val DarkOnTertiaryContainer = Color(0xFFCCFBF1)
private val DarkBackground = Color(0xFF1A1C1E) private val DarkBackground = Color(0xFF0B0F19) // Deep OLED Slate
private val DarkOnBackground = Color(0xFFE2E2E6) private val DarkOnBackground = Color(0xFFF1F5F9)
private val DarkSurface = Color(0xFF1A1C1E) private val DarkSurface = Color(0xFF111827) // Elev 1
private val DarkOnSurface = Color(0xFFE2E2E6) private val DarkOnSurface = Color(0xFFF1F5F9)
private val DarkSurfaceVariant = Color(0xFF43474E) private val DarkSurfaceVariant = Color(0xFF1F2937) // Elev 2
private val DarkOnSurfaceVariant = Color(0xFFC3C6CF) private val DarkOnSurfaceVariant = Color(0xFF9CA3AF)
private val DarkError = Color(0xFFFFB4AB) private val DarkError = Color(0xFFF87171)
private val DarkOnError = Color(0xFF690005) private val DarkOnError = Color(0xFF450A0A)
private val DarkErrorContainer = Color(0xFF93000A) private val DarkErrorContainer = Color(0xFF7F1D1D)
private val DarkOnErrorContainer = Color(0xFFFFDAD6) private val DarkOnErrorContainer = Color(0xFFFECACA)
private val DarkOutline = Color(0xFF8D9199) private val DarkOutline = Color(0xFF4B5563)
private val DarkOutlineVariant = Color(0xFF43474E) private val DarkOutlineVariant = Color(0xFF374151)
private val DarkInverseSurface = Color(0xFFE2E2E6) private val DarkInverseSurface = Color(0xFFF1F5F9)
private val DarkInverseOnSurface = Color(0xFF2F3033) private val DarkInverseOnSurface = Color(0xFF0F172A)
private val DarkInversePrimary = Color(0xFF0061A4) private val DarkInversePrimary = Color(0xFF2563EB)
private val DarkSurfaceTint = Color(0xFF9ECAFF) private val DarkSurfaceTint = Color(0xFF60A5FA)
// ============================================ // ============================================
// Site Colors - Premium Tones // One UI 9 Premium Accents & Brand Tones
// ============================================ // ============================================
// 뽐뿌 - Premium Pink // 뽐뿌 - Rose Magenta
val PpomppuColor = Color(0xFFE91E63) val PpomppuColor = Color(0xFFE11D48)
val PpomppuColorLight = Color(0xFFFCE4EC) val PpomppuColorLight = Color(0xFFFFF1F2)
val PpomppuColorDark = Color(0xFFC2185B) val PpomppuColorDark = Color(0xFF881337)
// 클리앙 - Premium Green // 클리앙 - Emerald Green
val ClienColor = Color(0xFF2E7D32) val ClienColor = Color(0xFF059669)
val ClienColorLight = Color(0xFFE8F5E9) val ClienColorLight = Color(0xFFECFDF5)
val ClienColorDark = Color(0xFF1B5E20) val ClienColorDark = Color(0xFF064E3B)
// 루리웹 - Premium Blue // 루리웹 - Royal Cobalt
val RuriwebColor = Color(0xFF1565C0) val RuriwebColor = Color(0xFF2563EB)
val RuriwebColorLight = Color(0xFFE3F2FD) val RuriwebColorLight = Color(0xFFEFF6FF)
val RuriwebColorDark = Color(0xFF0D47A1) val RuriwebColorDark = Color(0xFF1E3A8A)
// 쿨엔조이 - Premium Orange // 쿨엔조이 - Neon Sunset Orange
val CoolenjoyColor = Color(0xFFEF6C00) val CoolenjoyColor = Color(0xFFEA580C)
val CoolenjoyColorLight = Color(0xFFFFF3E0) val CoolenjoyColorLight = Color(0xFFFFF7ED)
val CoolenjoyColorDark = Color(0xFFE65100) val CoolenjoyColorDark = Color(0xFF7C2D12)
// Keyword Match - Light Red Theme // 아카라이브 - Vibrant Emerald Teal
val KeywordMatchColor = Color(0xFFE53935) val ArcaLiveColor = Color(0xFF0D9488)
val KeywordMatchColorLight = Color(0xFFFFEBEE) val ArcaLiveColorLight = Color(0xFFF0FDFA)
val ArcaLiveColorDark = Color(0xFF134E4A)
// Favorite - Premium Red // Hot / Popular Flame
val FavoriteColor = Color(0xFFE53935) 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 { fun getSiteColor(siteType: SiteType?): Color {
return when (siteType) { return when (siteType) {
@@ -131,12 +151,13 @@ fun getSiteColor(siteType: SiteType?): Color {
SiteType.CLIEN -> ClienColor SiteType.CLIEN -> ClienColor
SiteType.RURIWEB -> RuriwebColor SiteType.RURIWEB -> RuriwebColor
SiteType.COOLENJOY -> CoolenjoyColor SiteType.COOLENJOY -> CoolenjoyColor
null -> Color.Gray SiteType.ARCALIVE -> ArcaLiveColor
null -> Color(0xFF64748B)
} }
} }
/** /**
* 사이트별 라이트 배경색 가져오기 * 사이트별 라이트 소프트 틴트
*/ */
fun getSiteColorLight(siteType: SiteType?): Color { fun getSiteColorLight(siteType: SiteType?): Color {
return when (siteType) { return when (siteType) {
@@ -144,12 +165,13 @@ fun getSiteColorLight(siteType: SiteType?): Color {
SiteType.CLIEN -> ClienColorLight SiteType.CLIEN -> ClienColorLight
SiteType.RURIWEB -> RuriwebColorLight SiteType.RURIWEB -> RuriwebColorLight
SiteType.COOLENJOY -> CoolenjoyColorLight SiteType.COOLENJOY -> CoolenjoyColorLight
null -> Color.LightGray.copy(alpha = 0.3f) SiteType.ARCALIVE -> ArcaLiveColorLight
null -> Color(0xFFF1F5F9)
} }
} }
/** /**
* 사이트별 다크 배경색 가져오기 * 사이트별 다크 서브 틴트
*/ */
fun getSiteColorDark(siteType: SiteType?): Color { fun getSiteColorDark(siteType: SiteType?): Color {
return when (siteType) { return when (siteType) {
@@ -157,7 +179,8 @@ fun getSiteColorDark(siteType: SiteType?): Color {
SiteType.CLIEN -> ClienColorDark SiteType.CLIEN -> ClienColorDark
SiteType.RURIWEB -> RuriwebColorDark SiteType.RURIWEB -> RuriwebColorDark
SiteType.COOLENJOY -> CoolenjoyColorDark SiteType.COOLENJOY -> CoolenjoyColorDark
null -> Color.DarkGray SiteType.ARCALIVE -> ArcaLiveColorDark
null -> Color(0xFF1E293B)
} }
} }
@@ -246,9 +269,9 @@ fun HotDealTheme(
if (!view.isInEditMode) { if (!view.isInEditMode) {
SideEffect { SideEffect {
val window = (view.context as Activity).window val window = (view.context as Activity).window
// 시스템 바 색상을 앱 배경색과 일치시켜 이질감 제거 // One UI 9 Edge-to-Edge 완벽 투명 처리
window.statusBarColor = colorScheme.surface.toArgb() window.statusBarColor = colorScheme.background.toArgb()
window.navigationBarColor = colorScheme.surface.toArgb() window.navigationBarColor = colorScheme.background.toArgb()
WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme
WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme WindowCompat.getInsetsController(window, view).isAppearanceLightNavigationBars = !darkTheme
} }
@@ -7,133 +7,158 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
/** /**
* Premium Typography System * One UI 9 & Material 3 Expressive Typography System
* Material You 스타일의 세련된 타이포그래피 * 선명한 시각적 위계와 고가독성을 제공하는 타이포그래피
*/ */
val AppTypography = Typography( val AppTypography = Typography(
// ============================================
// Display - 대형 헤더 (사용 빈도 낮음)
// ============================================
displayLarge = TextStyle( displayLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Bold,
fontSize = 57.sp, fontSize = 40.sp,
lineHeight = 64.sp, lineHeight = 48.sp,
letterSpacing = (-0.25).sp letterSpacing = (-0.5).sp
), ),
displayMedium = TextStyle( displayMedium = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Light, fontWeight = FontWeight.Bold,
fontSize = 45.sp, fontSize = 32.sp,
lineHeight = 52.sp, lineHeight = 40.sp,
letterSpacing = 0.sp letterSpacing = (-0.4).sp
), ),
displaySmall = TextStyle( displaySmall = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.SemiBold,
fontSize = 36.sp, fontSize = 26.sp,
lineHeight = 44.sp, lineHeight = 34.sp,
letterSpacing = 0.sp letterSpacing = (-0.3).sp
), ),
// ============================================
// Headline - 중형 헤더
// ============================================
headlineLarge = TextStyle( headlineLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
fontSize = 32.sp, fontSize = 28.sp,
lineHeight = 40.sp, lineHeight = 36.sp,
letterSpacing = 0.sp letterSpacing = (-0.3).sp
), ),
headlineMedium = TextStyle( headlineMedium = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
fontSize = 28.sp, fontSize = 24.sp,
lineHeight = 36.sp, lineHeight = 32.sp,
letterSpacing = 0.sp letterSpacing = (-0.25).sp
), ),
headlineSmall = TextStyle( headlineSmall = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 24.sp, fontSize = 20.sp,
lineHeight = 32.sp, lineHeight = 28.sp,
letterSpacing = 0.sp letterSpacing = (-0.2).sp
), ),
// ============================================
// Title - 화면/섹션 타이틀
// ============================================
titleLarge = TextStyle( titleLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.Bold,
fontSize = 22.sp, fontSize = 20.sp,
lineHeight = 28.sp, lineHeight = 26.sp,
letterSpacing = 0.sp letterSpacing = (-0.2).sp
), ),
titleMedium = TextStyle( titleMedium = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
fontSize = 16.sp, fontSize = 16.sp,
lineHeight = 24.sp, lineHeight = 22.sp,
letterSpacing = 0.15.sp letterSpacing = (-0.15).sp
), ),
titleSmall = TextStyle( titleSmall = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, fontSize = 14.sp,
lineHeight = 20.sp, lineHeight = 20.sp,
letterSpacing = 0.1.sp letterSpacing = (-0.1).sp
), ),
// ============================================
// Body - 본문 텍스트
// ============================================
bodyLarge = TextStyle( bodyLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 16.sp, fontSize = 15.sp,
lineHeight = 24.sp, lineHeight = 22.sp,
letterSpacing = 0.5.sp letterSpacing = (-0.1).sp
), ),
bodyMedium = TextStyle( bodyMedium = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 14.sp, fontSize = 13.sp,
lineHeight = 20.sp, lineHeight = 18.sp,
letterSpacing = 0.25.sp letterSpacing = (-0.05).sp
), ),
bodySmall = TextStyle( bodySmall = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal, fontWeight = FontWeight.Normal,
fontSize = 12.sp, fontSize = 12.sp,
lineHeight = 16.sp, lineHeight = 16.sp,
letterSpacing = 0.4.sp letterSpacing = 0.sp
), ),
// ============================================
// Label - 버튼, 칩, 뱃지 등
// ============================================
labelLarge = TextStyle( labelLarge = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, fontSize = 13.sp,
lineHeight = 20.sp, lineHeight = 18.sp,
letterSpacing = 0.1.sp letterSpacing = (-0.1).sp
), ),
labelMedium = TextStyle( labelMedium = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 12.sp, fontSize = 12.sp,
lineHeight = 16.sp, lineHeight = 16.sp,
letterSpacing = 0.5.sp letterSpacing = 0.sp
), ),
labelSmall = TextStyle( labelSmall = TextStyle(
fontFamily = FontFamily.Default, fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
fontSize = 11.sp, 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, lineHeight = 16.sp,
letterSpacing = 0.5.sp letterSpacing = (-0.1).sp
)
) )
}
@@ -32,6 +32,7 @@ class ScraperTest {
testClien() testClien()
testRuriweb() testRuriweb()
testCoolenjoy() testCoolenjoy()
testArcaLive()
println("\n" + "#".repeat(60)) println("\n" + "#".repeat(60))
println("# 테스트 완료") 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" }) 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 @Test
fun `all SiteTypes should have at least one board`() { fun `all SiteTypes should have at least one board`() {
// When // When
+6 -7
View File
@@ -1,11 +1,10 @@
{ {
"version": "1.11.6", "version": "0.2.4",
"versionCode": 23, "versionCode": 24,
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases/download/v1.11.6/app-release.apk", "updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases/download/v0.2.4/app-release.apk",
"changelog": [ "changelog": [
"자동 업데이트 설치 권한 체크 추가", "아카라이브(ArcaLive) 핫딜 채널 지원 추가",
"업데이트 리시버 메모리 누수 수정", "설정 화면에서 아카라이브 핫딜 온/오프 제어 기능 추가",
"업데이트 체크 에러 처리 개선", "메인 화면 사이트 필터에 아카라이브 전용 필터링 칩 추가"
"설정 화면 업데이트 다운로드 진행률 표시"
] ]
} }