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