feat: UI/UX 대폭 개선 및 최적화 (v1.1.0)
- 알림 권한 설정 UI 상세화 (아이콘, 설명 추가) - 폴링 주기 오타 수정 (폴� -> 폴링) - 키워드 매칭 시각화 강화 (별 아이콘, 강한 테두리) - 하단 네비게이션 제거, 상단 앱바로 설정 이동 - 배터리/데이터 최적화 (데이터 보관 기간 7일 -> 3일) - 버전 업데이트 (1.0.1 -> 1.1.0)
This commit is contained in:
@@ -24,8 +24,8 @@ android {
|
||||
applicationId = "com.hotdeal.alarm"
|
||||
minSdk = 31
|
||||
targetSdk = 35
|
||||
versionCode = 2
|
||||
versionName = "1.0.1"
|
||||
versionCode = 3
|
||||
versionName = "1.1.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
|
||||
@@ -10,7 +10,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.FavoriteBorder
|
||||
import androidx.compose.material.icons.filled.NotificationsActive
|
||||
import androidx.compose.material.icons.filled.Star
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
@@ -45,28 +45,27 @@ fun DealItem(
|
||||
label = "favorite_scale"
|
||||
)
|
||||
|
||||
val siteColor = getSiteColor(deal.siteType)
|
||||
|
||||
// 키워드 매칭된 핫딜은 다른 색상으로 강조
|
||||
val cardColor = if (deal.isKeywordMatch) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
val borderColor = if (deal.isKeywordMatch) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.5f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
val cardModifier = if (deal.isKeywordMatch) {
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.border(2.dp, borderColor, MaterialTheme.shapes.large)
|
||||
} else {
|
||||
val siteColor = getSiteColor(deal.siteType)
|
||||
|
||||
// 키워드 매칭된 핫딜은 더 강한 시각적 강조
|
||||
val cardColor = if (deal.isKeywordMatch) {
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surface
|
||||
}
|
||||
|
||||
val borderColor = if (deal.isKeywordMatch) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
|
||||
val cardModifier = if (deal.isKeywordMatch) {
|
||||
modifier.fillMaxWidth()
|
||||
}
|
||||
.border(3.dp, borderColor, MaterialTheme.shapes.large)
|
||||
} else {
|
||||
modifier.fillMaxWidth()
|
||||
}
|
||||
|
||||
ElevatedCard(
|
||||
modifier = cardModifier,
|
||||
@@ -123,33 +122,34 @@ fun DealItem(
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
// 키워드 매칭 배지 (있을 때만)
|
||||
if (deal.isKeywordMatch) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.height(26.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.NotificationsActive,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
text = "키워드 매칭",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(Spacing.xs))
|
||||
}
|
||||
// 키워드 매칭 배지 - 별 아이콘으로 변경
|
||||
if (deal.isKeywordMatch) {
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.height(26.dp)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Star,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
Text(
|
||||
text = "내 키워드",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimary
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(Spacing.xs))
|
||||
}
|
||||
|
||||
// 공유 버튼
|
||||
IconButton(
|
||||
|
||||
@@ -56,44 +56,38 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
|
||||
BottomNavItem.values().forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = currentRoute == item.route,
|
||||
onClick = {
|
||||
navController.navigate(item.route) {
|
||||
popUpTo(navController.graph.startDestinationId) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||
label = { Text(item.label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.DealList.route,
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
composable(Screen.DealList.route) {
|
||||
DealListScreen(viewModel = viewModel)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("핫딜 알람") },
|
||||
actions = {
|
||||
// 설정 버튼
|
||||
IconButton(onClick = { navController.navigate(Screen.Settings.route) }) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Settings,
|
||||
contentDescription = "설정"
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = androidx.compose.material3.TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Screen.DealList.route,
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
composable(Screen.DealList.route) {
|
||||
DealListScreen(viewModel = viewModel)
|
||||
}
|
||||
composable(Screen.Settings.route) {
|
||||
SettingsScreen(viewModel = viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showPermissionDialog) {
|
||||
PermissionDialog(
|
||||
@@ -110,11 +104,4 @@ sealed class Screen(val route: String) {
|
||||
object Settings : Screen("settings")
|
||||
}
|
||||
|
||||
enum class BottomNavItem(
|
||||
val route: String,
|
||||
val label: String,
|
||||
val icon: androidx.compose.ui.graphics.vector.ImageVector
|
||||
) {
|
||||
Deals("deal_list", "핫딜", Icons.Default.List),
|
||||
Settings("settings", "설정", Icons.Default.Settings)
|
||||
}
|
||||
|
||||
|
||||
@@ -167,37 +167,89 @@ fun SettingsScreen(viewModel: MainViewModel) {
|
||||
|
||||
@Composable
|
||||
fun PermissionCard(
|
||||
permissionStatus: PermissionHelper.PermissionStatus,
|
||||
hasEnabledSites: Boolean,
|
||||
onRequestPermission: () -> Unit
|
||||
permissionStatus: PermissionHelper.PermissionStatus,
|
||||
hasEnabledSites: Boolean,
|
||||
onRequestPermission: () -> Unit
|
||||
) {
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("알림 설정", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (permissionStatus.hasNotificationPermission) Icons.Default.Check else Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = if (permissionStatus.hasNotificationPermission) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text("알림 권한", modifier = Modifier.weight(1f))
|
||||
if (!permissionStatus.hasNotificationPermission) {
|
||||
Button(onClick = onRequestPermission) { Text("설정") }
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (hasEnabledSites) Icons.Default.Check else Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = if (hasEnabledSites) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Text("사이트 선택")
|
||||
}
|
||||
}
|
||||
}
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("알림 설정", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
// 알림 권한 상세 안내
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = if (permissionStatus.hasNotificationPermission)
|
||||
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
|
||||
else
|
||||
MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = if (permissionStatus.hasNotificationPermission) Icons.Default.CheckCircle else Icons.Default.Warning,
|
||||
contentDescription = null,
|
||||
tint = if (permissionStatus.hasNotificationPermission) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = if (permissionStatus.hasNotificationPermission) "알림 권한 허용됨" else "알림 권한 필요",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = if (permissionStatus.hasNotificationPermission)
|
||||
"키워드 매칭 시 즉시 알림을 받을 수 있습니다"
|
||||
else
|
||||
"키워드 매칭 알림을 받으려면 권한을 허용해주세요",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!permissionStatus.hasNotificationPermission) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Button(
|
||||
onClick = onRequestPermission,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(Icons.Default.Notifications, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text("알림 권한 허용하기")
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
HorizontalDivider()
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// 사이트 선택 상태
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Icon(
|
||||
imageVector = if (hasEnabledSites) Icons.Default.Check else Icons.Default.Close,
|
||||
contentDescription = null,
|
||||
tint = if (hasEnabledSites) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Column {
|
||||
Text("사이트 선택", style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium)
|
||||
Text(
|
||||
text = if (hasEnabledSites) "모니터링할 사이트가 선택되었습니다" else "최소 1개 사이트를 선택해주세요",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -205,7 +257,7 @@ fun PollingIntervalCard(currentInterval: Int, onIntervalChange: (Long) -> Unit)
|
||||
var selected by remember(currentInterval) { mutableStateOf(currentInterval) }
|
||||
Card(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text("폴<EFBFBD> 주기", style = MaterialTheme.typography.titleMedium)
|
||||
Text("폴링 주기", style = MaterialTheme.typography.titleMedium)
|
||||
Text("2분 권장", style = MaterialTheme.typography.bodySmall)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
listOf(1, 2, 5, 10, 15, 30).forEach { minutes ->
|
||||
|
||||
@@ -116,9 +116,9 @@ class HotDealPollingWorker @AssistedInject constructor(
|
||||
// 8. 알림 발송 상태 업데이트
|
||||
dealDao.markAsNotified(newDeals.map { it.id })
|
||||
|
||||
// 9. 오래된 데이터 정리 (7일 이상)
|
||||
val threshold = System.currentTimeMillis() - (7 * 24 * 60 * 60 * 1000L)
|
||||
dealDao.deleteOldDeals(threshold)
|
||||
// 9. 오래된 데이터 정리 (3일 이상 - 배터리/데이터 절약)
|
||||
val threshold = System.currentTimeMillis() - (3 * 24 * 60 * 60 * 1000L)
|
||||
dealDao.deleteOldDeals(threshold)
|
||||
|
||||
Log.d(TAG, "===== 핫딜 폴<> 완료 =====")
|
||||
Result.success()
|
||||
|
||||
@@ -31,10 +31,11 @@ class WorkerScheduler @Inject constructor(
|
||||
// 최소/최대 값 제한
|
||||
val safeInterval = intervalMinutes.coerceIn(MIN_INTERVAL_MINUTES, MAX_INTERVAL_MINUTES)
|
||||
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true) // 배터리 부족 시 실행 안 함
|
||||
.build()
|
||||
val constraints = Constraints.Builder()
|
||||
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||
.setRequiresBatteryNotLow(true) // 배터리 부족 시 실행 안 함
|
||||
.setRequiresDeviceIdle(false) // 화면 켜진 상태에서도 실행
|
||||
.build()
|
||||
|
||||
// 유연한 실행 시간 (±25%)
|
||||
val flexTime = (safeInterval * 0.25).toLong().coerceAtLeast(1)
|
||||
|
||||
Reference in New Issue
Block a user