feat: UI/UX 대폭 개선 및 최적화 (v1.1.0)

- 알림 권한 설정 UI 상세화 (아이콘, 설명 추가)
- 폴링 주기 오타 수정 (폴� -> 폴링)
- 키워드 매칭 시각화 강화 (별 아이콘, 강한 테두리)
- 하단 네비게이션 제거, 상단 앱바로 설정 이동
- 배터리/데이터 최적화 (데이터 보관 기간 7일 -> 3일)
- 버전 업데이트 (1.0.1 -> 1.1.0)
This commit is contained in:
sanjeok77
2026-03-04 02:49:32 +09:00
parent c67bb57be7
commit 745dd1a174
7 changed files with 185 additions and 142 deletions

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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)
}

View File

@@ -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 ->

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)", "알림 권한 설정 상세화",
"버그 수정" "키워드 매칭 시각화 강화 (별 아이콘)",
] "하단 메뉴 제거, 상단 앱바로 이동",
"배터리/데이터 최적화",
"폴링 주기 오타 수정"
]
} }
} }