37 Commits
Author SHA1 Message Date
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
sanjeok77 ba0cdf3dd6 Fix: Correct updateUrl in version.json 2026-03-17 07:57:56 +09:00
sanjeok77andSisyphus b2e15c8111 Fix auto-update issues: receiver lifecycle, permission checks, error handling
- SettingsScreen: Add DisposableEffect for BroadcastReceiver lifecycle management
- MainActivity: Add error logging and permission checks before download
- ApkDownloadManager: Add install permission validation with Settings.canRequestPackageInstalls()
- Add user feedback with Toast messages for all error cases
- Bump version to 1.11.6

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-17 07:40:47 +09:00
20 changed files with 2206 additions and 1777 deletions
+2 -2
View File
@@ -24,8 +24,8 @@ android {
applicationId = "com.hotdeal.alarm"
minSdk = 31
targetSdk = 35
versionCode = 22
versionName = "1.11.5"
versionCode = 27
versionName = "0.2.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
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 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 {
@@ -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()
}
this
.scale(scale)
.clickable(
interactionSource = source,
indication = null, // 불필요한 기본 리플 번짐을 끄고 Elastic Scale로 최고급 감각 제공
enabled = enabled,
onClick = onClick
)
}
LaunchedEffect(isPressed) {
if (isPressed) {
kotlinx.coroutines.delay(100)
isPressed = false
/**
* One UI 9 Squircle Container Card with Ambient Border
*/
@Composable
fun SquircleCard(
modifier: Modifier = Modifier,
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
.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
) {
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 LoadingOverlay(
isLoading: Boolean,
fun NowStatusPill(
text: String,
modifier: Modifier = Modifier,
content: @Composable () -> Unit
isLive: Boolean = true,
accentColor: Color = MaterialTheme.colorScheme.primary
) {
Box(modifier = modifier) {
content()
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"
)
AnimatedVisibility(
visible = isLoading,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier.matchParentSize()
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
.fillMaxSize()
.padding(Spacing.lg),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
.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,264 +34,289 @@ 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)
tint = if (deal.isFavorite) FavoriteColor else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
modifier = Modifier.size(16.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)
)
}
}
}
}
}
}
}
private fun formatTime(timestamp: Long): String {
/**
* 핫딜 제목에서 상점 태그 및 가격을 분리하는 헬퍼
*/
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()
}
}
// 끝부분 가격 추출: (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
@@ -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(
@@ -46,21 +54,21 @@ fun DealListScreen(
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val context = LocalContext.current
val focusManager = LocalFocusManager.current
// Pull to Refresh 상태 - 민감도 대폭 향상
val pullToRefreshState = rememberPullToRefreshState(
positionalThreshold = 40.dp // 기본값의 1/3로 설정하여 매우 쉽게 새로고침
)
// 스크롤 시 상단바 자동 축소/숨김 동작
val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior(rememberTopAppBarState())
// Pull to Refresh
val pullToRefreshState = rememberPullToRefreshState(positionalThreshold = 40.dp)
var isRefreshing by remember { mutableStateOf(false) }
// 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
@@ -68,79 +76,131 @@ fun DealListScreen(
}
}
// 검색 모드 및 필터 상태
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
)
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 color = getSiteColor(siteType)
EnhancedFilterChip(
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,7 +405,7 @@ fun DealListScreen(
}
}
// Pull to Refresh 인디케이터 - 절대 위치로 배치하여 간격 벌어짐 방지
// Pull to Refresh 인디케이터
val progress = pullToRefreshState.progress
val showIndicator = pullToRefreshState.isRefreshing || progress > 0
@@ -433,14 +414,13 @@ fun DealListScreen(
state = pullToRefreshState,
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 100.dp)
.padding(top = 50.dp)
.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
@@ -451,46 +431,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
)
}
}
@@ -2,6 +2,7 @@ package com.hotdeal.alarm.presentation.main
import android.content.BroadcastReceiver
import android.os.Bundle
import android.util.Log
import android.view.View
import android.view.WindowInsets
import android.view.WindowManager
@@ -21,6 +22,7 @@ import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.lifecycleScope
import com.hotdeal.alarm.ui.theme.HotDealTheme
import com.hotdeal.alarm.util.ApkDownloadManager
import com.hotdeal.alarm.util.PermissionHelper
import com.hotdeal.alarm.util.UpdateInfo
import com.hotdeal.alarm.util.VersionManager
import com.hotdeal.alarm.worker.WorkerScheduler
@@ -31,6 +33,10 @@ import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
companion object {
private const val TAG = "MainActivity"
}
@Inject
lateinit var workerScheduler: WorkerScheduler
@@ -55,6 +61,7 @@ class MainActivity : ComponentActivity() {
// 업데이트 체크
var updateInfo by remember { mutableStateOf<UpdateInfo?>(null) }
var showUpdateDialog by remember { mutableStateOf(false) }
var showPermissionDialog by remember { mutableStateOf(false) }
var downloadProgress by remember { mutableStateOf(0) }
var isDownloading by remember { mutableStateOf(false) }
var downloadId by remember { mutableStateOf(-1L) }
@@ -78,7 +85,11 @@ class MainActivity : ComponentActivity() {
if (!isDownloading) showUpdateDialog = false
},
onUpdate = {
// APK 다이렉트 다운로드
if (!PermissionHelper.canInstallUnknownApps(this@MainActivity)) {
showPermissionDialog = true
return@UpdateDialog
}
isDownloading = true
downloadId = ApkDownloadManager.downloadApk(
this@MainActivity,
@@ -123,6 +134,31 @@ class MainActivity : ComponentActivity() {
}
)
}
if (showPermissionDialog) {
AlertDialog(
onDismissRequest = { showPermissionDialog = false },
title = { Text("설치 권한 필요") },
text = {
Text("알 수 없는 소스의 앱 설치를 허용해야 업데이트를 설치할 수 있습니다. 설정에서 권한을 허용해주세요.")
},
confirmButton = {
Button(
onClick = {
PermissionHelper.openUnknownAppsSettings(this@MainActivity)
showPermissionDialog = false
}
) {
Text("설정 열기")
}
},
dismissButton = {
TextButton(onClick = { showPermissionDialog = false }) {
Text("취소")
}
}
)
}
}
}
}
@@ -211,7 +247,12 @@ class MainActivity : ComponentActivity() {
onUpdateAvailable(remoteInfo)
}
} catch (e: Exception) {
// 업데이트 체크 실패 시 무시
Log.e(TAG, "업데이트 체크 실패", e)
Toast.makeText(
this@MainActivity,
"업데이트 확인 실패: 네트워크 연결을 확인하세요",
Toast.LENGTH_SHORT
).show()
}
}
}
@@ -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(54.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(20.dp)
.height(3.dp)
.clip(CornerRadius.shapePill)
.background(MaterialTheme.colorScheme.primary)
)
Spacer(modifier = Modifier.height(3.dp))
} else {
Spacer(modifier = Modifier.height(6.dp))
}
Icon(
imageVector = if (isSelected) selectedIcon else unselectedIcon,
contentDescription = label,
tint = itemColor,
modifier = Modifier.size(22.dp)
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = label,
style = MaterialTheme.typography.labelSmall.copy(
fontSize = 11.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")
}
@@ -48,8 +48,9 @@ class MainViewModel @Inject constructor(
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}",
@@ -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
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 = 16.sp,
letterSpacing = 0.5.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.1).sp
)
}
@@ -8,6 +8,7 @@ import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.core.content.FileProvider
@@ -193,13 +194,22 @@ object ApkDownloadManager {
/**
* APK 파일 설치
* @return Boolean true if installation started, false if permission denied
*/
fun installApk(context: Context) {
fun installApk(context: Context): Boolean {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
if (!context.packageManager.canRequestPackageInstalls()) {
Log.w(TAG, "앱 설치 권한 없음")
Toast.makeText(context, "설치 권한이 필요합니다. 설정에서 허용해주세요.", Toast.LENGTH_SHORT).show()
return false
}
}
val apkFile = File(context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), APK_FILE_NAME)
if (!apkFile.exists()) {
Toast.makeText(context, "APK 파일을 찾을 수 없습니다", Toast.LENGTH_SHORT).show()
return
return false
}
try {
@@ -216,8 +226,10 @@ object ApkDownloadManager {
}
context.startActivity(intent)
return true
} catch (e: Exception) {
Toast.makeText(context, "설치를 시작할 수 없습니다: ${e.message}", Toast.LENGTH_SHORT).show()
return 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
+133
View File
@@ -0,0 +1,133 @@
#!/bin/bash
#
# Android TV App 자동 릴리즈 스크립트
# 사용법: ./release.sh [버전타입] (major|minor|patch)
# 예: ./release.sh patch
set -e
# 색상 정의
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 현재 버전 정보 가져오기
CURRENT_VERSION=$(grep -o 'versionName = "[^"]*"' app/build.gradle.kts | cut -d'"' -f2)
CURRENT_CODE=$(grep -o 'versionCode = [0-9]*' app/build.gradle.kts | grep -o '[0-9]*')
# 버전 타입 (기본값: patch)
VERSION_TYPE=${1:-patch}
echo -e "${YELLOW}현재 버전: v${CURRENT_VERSION} (build ${CURRENT_CODE})${NC}"
echo -e "${YELLOW}업데이트 타입: ${VERSION_TYPE}${NC}"
# 새 버전 계산
IFS='.' read -r -a VERSION_PARTS <<< "$CURRENT_VERSION"
MAJOR=${VERSION_PARTS[0]}
MINOR=${VERSION_PARTS[1]}
PATCH=${VERSION_PARTS[2]}
if [ "$VERSION_TYPE" = "major" ]; then
MAJOR=$((MAJOR + 1))
MINOR=0
PATCH=0
elif [ "$VERSION_TYPE" = "minor" ]; then
MINOR=$((MINOR + 1))
PATCH=0
else
PATCH=$((PATCH + 1))
fi
NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
NEW_CODE=$((CURRENT_CODE + 1))
echo -e "${GREEN}새 버전: v${NEW_VERSION} (build ${NEW_CODE})${NC}"
# 확인
read -p "계속하시겠습니까? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo -e "${RED}취소되었습니다.${NC}"
exit 1
fi
# 1. build.gradle.kts 업데이트
echo -e "${YELLOW}1. build.gradle.kts 업데이트 중...${NC}"
sed -i "s/versionCode = ${CURRENT_CODE}/versionCode = ${NEW_CODE}/" app/build.gradle.kts
sed -i "s/versionName = \"${CURRENT_VERSION}\"/versionName = \"${NEW_VERSION}\"/" app/build.gradle.kts
echo -e "${GREEN} 완료${NC}"
# 2. version.json 업데이트
echo -e "${YELLOW}2. version.json 업데이트 중...${NC}"
cat > version.json << EOF
{
"versionCode": ${NEW_CODE},
"versionName": "${NEW_VERSION}",
"apkUrl": "https://git.webpluss.net/sanjeok77/NeFLIX/releases/download/v${NEW_VERSION}/app-release.apk",
"updateMessage": "v${NEW_VERSION} 업데이트\n- "
}
EOF
echo -e "${GREEN} 완료${NC}"
echo -e "${YELLOW} ⚠️ version.json의 updateMessage를 수동으로 수정하세요!${NC}"
# 3. 변경사항 스테이징
echo -e "${YELLOW}3. Git 스테이징 중...${NC}"
git add app/build.gradle.kts version.json
echo -e "${GREEN} 완료${NC}"
# 4. 커밋
echo -e "${YELLOW}4. Git 커밋 중...${NC}"
echo "커밋 메시지를 입력하세요 (기본값: 'feat: 버전 업데이트 v${NEW_VERSION}'):"
read -r COMMIT_MSG
if [ -z "$COMMIT_MSG" ]; then
COMMIT_MSG="feat: 버전 업데이트 v${NEW_VERSION}"
fi
git commit -m "$COMMIT_MSG" -m "" -m "- versionCode: ${NEW_CODE}" -m "- versionName: ${NEW_VERSION}" -m "" -m "Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)" -m "Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>"
echo -e "${GREEN} 완료${NC}"
# 5. 태그 생성
echo -e "${YELLOW}5. Git 태그 생성 중...${NC}"
echo "태그 메시지를 입력하세요 (기본값: 'v${NEW_VERSION}'):"
read -r TAG_MSG
if [ -z "$TAG_MSG" ]; then
TAG_MSG="v${NEW_VERSION}"
fi
git tag -a "v${NEW_VERSION}" -m "$TAG_MSG"
echo -e "${GREEN} 완료${NC}"
# 6. 푸시
echo -e "${YELLOW}6. Git 푸시 중...${NC}"
git push origin main
git push origin "v${NEW_VERSION}"
echo -e "${GREEN} 완료${NC}"
# 7. 릴리즈 빌드
echo -e "${YELLOW}7. 릴리즈 빌드 중...${NC}"
./gradlew assembleRelease
echo -e "${GREEN} 완료${NC}"
# 8. 릴리즈 정보 출력
echo ""
echo -e "${GREEN}========================================${NC}"
echo -e "${GREEN}릴리즈 준비 완료!${NC}"
echo -e "${GREEN}========================================${NC}"
echo ""
echo "다음 단계:"
echo "1. Gitea에서 릴리즈를 생성하세요:"
echo " URL: https://git.webpluss.net/sanjeok77/NeFLIX/releases/new"
echo ""
echo "2. 또는 curl 명령어로 생성:"
echo " curl -X POST \"https://git.webpluss.net/api/v1/repos/sanjeok77/NeFLIX/releases\" \\"
echo " -H \"Authorization: token \$TOKEN\" \\"
echo " -H \"Content-Type: application/json; charset=utf-8\" \\"
echo " -d '{\"tag_name\": \"v${NEW_VERSION}\", \"name\": \"v${NEW_VERSION}\", \"body\": \"v${NEW_VERSION} 업데이트\\n- \", \"draft\": false, \"prerelease\": false}'"
echo ""
echo "3. APK 업로드:"
echo " curl -X POST \"https://git.webpluss.net/api/v1/repos/sanjeok77/NeFLIX/releases/<release_id>/assets?name=app-release.apk\" \\"
echo " -H \"Authorization: token \$TOKEN\" \\"
echo " -H \"Content-Type: application/octet-stream\" \\"
echo " --data-binary @\"app/build/outputs/apk/release/app-release.apk\""
echo ""
echo -e "${YELLOW}⚠️ version.json의 updateMessage를 수정한 후 다시 커밋하세요!${NC}"
echo ""
+6 -7
View File
@@ -1,11 +1,10 @@
{
"version": "1.11.5",
"versionCode": 22,
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases/download/v1.11.5/app-release.apk",
"version": "0.2.7",
"versionCode": 27,
"updateUrl": "https://git.webpluss.net/sanjeok77/hotdeal_alarm/releases/download/v0.2.7/app-release.apk",
"changelog": [
"비저빌리티 새로고침 버그 수정",
"폴링 간격 설정이 저장되지 않던 문제 수정",
"배터리 부족 시에도 폴링 동작하도록 개선",
"부팅 시 저장된 폴링 간격 사용하도록 수정"
"하단 고정 메뉴 일체형(Docked) 디자인 개선 (떠 있는 이질감 완전 제거)",
"시스템 내비게이션 바닥까지 끊김 없는 배경 처리 및 0.6dp 헤어라인 구분선",
"상단 미니 캡슐 인디케이터와 One UI 9 네이티브 탭 전환 감각 적용"
]
}