diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e086e1e0..0681f262 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { alias(libs.plugins.hilt) alias(libs.plugins.ksp) alias(libs.plugins.google.services) + alias(libs.plugins.firebase.crashlytics) } val localProperties = @@ -21,8 +22,8 @@ android { defaultConfig { applicationId = "com.sseotdabwa.buyornot" - versionCode = 1 - versionName = "1.0.0" + versionCode = 2 + versionName = "0.0.1" buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"${localProperties.getProperty("kakao.nativeAppKey", "")}\"") manifestPlaceholders["NATIVE_APP_KEY"] = localProperties.getProperty("kakao.nativeAppKey", "") @@ -59,7 +60,8 @@ android { signingConfig = signingConfigs.getByName("release") } release { - isMinifyEnabled = false + isMinifyEnabled = true + isShrinkResources = true proguardFiles( getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", @@ -104,6 +106,7 @@ dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) + implementation(libs.firebase.crashlytics) ksp(libs.hilt.compiler) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 481bb434..a34574f4 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -1,21 +1,171 @@ -# Add project specific ProGuard rules here. -# You can control the set of applied configuration files using the -# proguardFiles setting in build.gradle. -# -# For more details, see -# http://developer.android.com/guide/developing/tools/proguard.html - -# If your project uses WebView with JS, uncomment the following -# and specify the fully qualified class name to the JavaScript interface -# class: -#-keepclassmembers class fqcn.of.javascript.interface.for.webview { -# public *; -#} - -# Uncomment this to preserve the line number information for -# debugging stack traces. -#-keepattributes SourceFile,LineNumberTable - -# If you keep the line number information, uncomment this to -# hide the original source file name. -#-renamesourcefileattribute SourceFile \ No newline at end of file +# ============================================================ +# [Common] Android +# ============================================================ +-keepattributes SourceFile,LineNumberTable +-keepattributes *Annotation* +-keepattributes Signature +-keepattributes InnerClasses +-keepattributes Exceptions +-dontwarn sun.misc.** +-dontwarn javax.annotation.** + +# Enum 보존 +-keepclassmembers enum * { + public static **[] values(); + public static ** valueOf(java.lang.String); +} + +# Parcelable 보존 +-keep class * implements android.os.Parcelable { + public static final ** CREATOR; +} + +# Serializable 보존 +-keepclassmembers class * implements java.io.Serializable { + static final long serialVersionUID; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); +} + +# ============================================================ +# [Kotlin] +# ============================================================ +-keep class kotlin.Metadata { *; } +-dontwarn kotlin.** +-keepclassmembers class **$WhenMappings { + ; +} + +# ============================================================ +# [Kotlinx Coroutines] +# ============================================================ +-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {} +-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {} +-keepclassmembernames class kotlinx.** { + volatile ; +} +-dontwarn kotlinx.coroutines.** + +# ============================================================ +# [Kotlinx Serialization] +# ============================================================ +-keep class kotlinx.serialization.** { *; } +-keepclassmembers @kotlinx.serialization.Serializable class * { + *** Companion; + *** INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} +-keepclassmembers class * { + @kotlinx.serialization.Serializable *; +} +-keepclassmembers class * { + kotlinx.serialization.KSerializer serializer(...); +} +-keepclassmembers class kotlinx.serialization.json.** { + *** Companion; +} +-dontwarn kotlinx.serialization.** + +# ============================================================ +# [Hilt / Dagger] +# ============================================================ +-keep class dagger.hilt.** { *; } +-keep class com.google.dagger.** { *; } +-keep @dagger.hilt.android.lifecycle.HiltViewModel class * { *; } +-keep @dagger.hilt.InstallIn class * { *; } +-keep interface * extends javax.inject.Provider +-dontwarn dagger.hilt.** + +# ============================================================ +# [Retrofit / OkHttp] +# ============================================================ +-keep class retrofit2.** { *; } +-dontwarn retrofit2.** +-keepclassmembers class * { + @retrofit2.http.* ; +} +-keepclassmembernames interface * { + @retrofit2.http.* ; +} +-keep class okhttp3.** { *; } +-dontwarn okhttp3.** +-dontwarn okio.** + +# ============================================================ +# [Jetpack Compose] +# ============================================================ +-keep class androidx.compose.runtime.RecomposeScopeImpl { *; } +-keep class * implements androidx.compose.runtime.Stable { *; } +-keepclassmembers class * { + @androidx.compose.runtime.Composable ; +} + +# ============================================================ +# [AndroidX Lifecycle / ViewModel] +# ============================================================ +-keep class * extends androidx.lifecycle.ViewModel { *; } +-keep class * extends androidx.lifecycle.AndroidViewModel { *; } +-keepclassmembers class * extends androidx.lifecycle.ViewModel { + (...); +} + +# ============================================================ +# [Navigation Compose] +# ============================================================ +-keep class androidx.navigation.** { *; } + +# ============================================================ +# [AndroidX DataStore] +# ============================================================ +-keep class androidx.datastore.** { *; } + +# ============================================================ +# [AndroidX Credentials / Google Sign-In] +# ============================================================ +-keep class androidx.credentials.** { *; } +-keep class com.google.android.libraries.identity.googleid.** { *; } +-dontwarn com.google.android.libraries.identity.googleid.** + +# ============================================================ +# [Coil] +# ============================================================ +-keep class coil.** { *; } +-dontwarn coil.** + +# ============================================================ +# [Firebase] +# ============================================================ +-keep class com.google.firebase.** { *; } +-dontwarn com.google.firebase.** + +# Crashlytics: 스택 트레이스 보존을 위해 Exception 하위 클래스 유지 +-keep public class * extends java.lang.Exception + +# ============================================================ +# [Lottie] +# ============================================================ +-keep class com.airbnb.lottie.** { *; } +-dontwarn com.airbnb.lottie.** + +# ============================================================ +# [Kakao SDK] +# ============================================================ +-keep class com.kakao.sdk.** { *; } +-dontwarn com.kakao.sdk.** + +# ============================================================ +# [Domain / Data Models] - API 통신 데이터 클래스 보존 +# ============================================================ +# Domain models +-keep class com.sseotdabwa.buyornot.domain.model.** { *; } + +# Network DTOs (request / response) +-keep class com.sseotdabwa.buyornot.core.network.dto.** { *; } + +# DataStore preferences holder +-keep class com.sseotdabwa.buyornot.core.datastore.** { *; } + +# ============================================================ +# [App] Firebase Cloud Messaging Service +# ============================================================ +-keep class com.sseotdabwa.buyornot.notification.** { *; } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 2afd70c0..cf13dc03 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + Unit = {}, + viewModel: BuyOrNotViewModel = hiltViewModel(), ) { val navController = rememberNavController() val snackbarState = rememberBuyOrNotSnackbarState() val navBackStackEntry by navController.currentBackStackEntryAsState() val currentDestination = navBackStackEntry?.destination + val isFirstRun by viewModel.isFirstRun.collectAsStateWithLifecycle() + // 홈 화면에서 뒤로가기 시 앱 종료 BackHandler(enabled = currentDestination?.route == HOME_ROUTE) { onBackPressed() } - // 앱 진입 시 알림 권한 자동 요청 + // 앱 진입 시 최초 1회만 알림 권한 자동 요청 val (hasNotificationPermission, requestNotificationPermission) = rememberNotificationPermission() - LaunchedEffect(Unit) { - if (!hasNotificationPermission) { - requestNotificationPermission() + LaunchedEffect(isFirstRun) { + if (isFirstRun) { + if (!hasNotificationPermission) { + requestNotificationPermission() + } + viewModel.updateIsFirstRun(false) } } diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt new file mode 100644 index 00000000..3035439a --- /dev/null +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt @@ -0,0 +1,29 @@ +package com.sseotdabwa.buyornot.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.sseotdabwa.buyornot.domain.repository.AppPreferencesRepository +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class BuyOrNotViewModel @Inject constructor( + private val appPreferencesRepository: AppPreferencesRepository, +) : ViewModel() { + val isFirstRun = + appPreferencesRepository.isFirstRun + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = false, + ) + + fun updateIsFirstRun(isFirstRun: Boolean) { + viewModelScope.launch { + appPreferencesRepository.updateIsFirstRun(isFirstRun) + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 91cae972..b749cc24 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,6 +8,7 @@ plugins { alias(libs.plugins.ksp) apply false alias(libs.plugins.ktlint) apply false alias(libs.plugins.google.services) apply false + alias(libs.plugins.firebase.crashlytics) apply false } apply(from = "gradle/dependencyGraph.gradle") diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt index 8dabecbc..3f2de7bb 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AppPreferencesRepositoryImpl.kt @@ -15,7 +15,14 @@ class AppPreferencesRepositoryImpl @Inject constructor( override val hasRequestedNotificationPermission: Flow = appPreferencesDataSource.hasRequestedNotificationPermission + override val isFirstRun: Flow = + appPreferencesDataSource.isFirstRun + override suspend fun updateNotificationPermissionRequested(requested: Boolean) { appPreferencesDataSource.updateNotificationPermissionRequested(requested) } + + override suspend fun updateIsFirstRun(isFirstRun: Boolean) { + appPreferencesDataSource.updateIsFirstRun(isFirstRun) + } } diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AuthRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AuthRepositoryImpl.kt index 1452eb7b..92bc5d62 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AuthRepositoryImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/AuthRepositoryImpl.kt @@ -44,7 +44,7 @@ class AuthRepositoryImpl @Inject constructor( authApiService.logout(RefreshRequest(refreshToken)).getOrThrow() } - override suspend fun clearTokens() { - userPreferencesDataSource.clearTokens() + override suspend fun clearUserInfo() { + userPreferencesDataSource.clearUserInfo() } } diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/FeedRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/FeedRepositoryImpl.kt index a2348b18..02532cd5 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/FeedRepositoryImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/FeedRepositoryImpl.kt @@ -150,7 +150,7 @@ private fun FeedItemDto.toDomain(): Feed = Feed( feedId = feedId, content = content, - price = price, + price = String.format(java.util.Locale.KOREA, "%,d", price), category = category.toFeedCategory(), yesCount = yesCount, noCount = noCount, diff --git a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/UserPreferencesRepositoryImpl.kt b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/UserPreferencesRepositoryImpl.kt index 5794c262..174a70f0 100644 --- a/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/UserPreferencesRepositoryImpl.kt +++ b/core/data/src/main/java/com/sseotdabwa/buyornot/core/data/repository/UserPreferencesRepositoryImpl.kt @@ -1,11 +1,14 @@ package com.sseotdabwa.buyornot.core.data.repository import com.sseotdabwa.buyornot.core.datastore.UserPreferencesDataSource +import com.sseotdabwa.buyornot.domain.model.UserPreferences +import com.sseotdabwa.buyornot.domain.model.UserToken import com.sseotdabwa.buyornot.domain.model.UserType import com.sseotdabwa.buyornot.domain.repository.UserPreferencesRepository import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import javax.inject.Inject +import com.sseotdabwa.buyornot.core.datastore.UserPreferences as DatastoreUserPreferences import com.sseotdabwa.buyornot.core.datastore.UserType as DatastoreUserType /** @@ -15,14 +18,47 @@ import com.sseotdabwa.buyornot.core.datastore.UserType as DatastoreUserType class UserPreferencesRepositoryImpl @Inject constructor( private val userPreferencesDataSource: UserPreferencesDataSource, ) : UserPreferencesRepository { + override val userPreferences: Flow = + userPreferencesDataSource.preferences.map { it.toDomain() } + + override val userToken: Flow = + userPreferencesDataSource.preferences.map { it.toTokenDomain() } + override val userType: Flow = userPreferencesDataSource.userType.map { it.toDomain() } override suspend fun updateUserType(userType: UserType) { userPreferencesDataSource.updateUserType(userType.toDatastore()) } + + override suspend fun updateDisplayName(newName: String) { + userPreferencesDataSource.updateDisplayName(newName) + } + + override suspend fun updateProfileImageUrl(newUrl: String) { + userPreferencesDataSource.updateProfileImageUrl(newUrl) + } } +/** + * DataStore UserPreferences → Domain UserPreferences + */ +private fun DatastoreUserPreferences.toDomain(): UserPreferences = + UserPreferences( + displayName = displayName, + profileImageUrl = profileImageUrl, + userType = userType.toDomain(), + ) + +/** + * DataStore UserPreferences → Domain UserToken + */ +private fun DatastoreUserPreferences.toTokenDomain(): UserToken = + UserToken( + accessToken = accessToken, + refreshToken = refreshToken, + ) + /** * DataStore UserType → Domain UserType */ diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferences.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferences.kt index e15687af..086097b9 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferences.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferences.kt @@ -12,11 +12,7 @@ data class AppPreferences( */ val hasRequestedNotificationPermission: Boolean = false, /** - * 향후 추가될 앱 설정들 - * 예: 다크모드, 언어 설정, 첫 실행 여부 등 - * - * val isDarkModeEnabled: Boolean = false, - * val preferredLanguage: String = "ko", - * val isFirstLaunch: Boolean = true, + * 최초 실행 여부 */ + val isFirstRun: Boolean = true, ) diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt index 6e69a299..71d72c13 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSource.kt @@ -18,8 +18,18 @@ interface AppPreferencesDataSource { */ val hasRequestedNotificationPermission: Flow + /** + * 최초 실행 여부를 Flow로 제공 + */ + val isFirstRun: Flow + /** * 알림 권한 요청 이력 업데이트 */ suspend fun updateNotificationPermissionRequested(requested: Boolean) + + /** + * 최초 실행 여부 업데이트 + */ + suspend fun updateIsFirstRun(isFirstRun: Boolean) } diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt index 32e0736f..0b04c0d4 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/AppPreferencesDataSourceImpl.kt @@ -25,12 +25,14 @@ class AppPreferencesDataSourceImpl ) : AppPreferencesDataSource { private object Keys { val HAS_REQUESTED_NOTIFICATION_PERMISSION = booleanPreferencesKey("has_requested_notification_permission") + val IS_FIRST_RUN = booleanPreferencesKey("is_first_run") } override val preferences: Flow = context.appPreferencesDataStore.data.map { prefs -> AppPreferences( hasRequestedNotificationPermission = prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] ?: false, + isFirstRun = prefs[Keys.IS_FIRST_RUN] ?: false, ) } @@ -39,9 +41,20 @@ class AppPreferencesDataSourceImpl prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] ?: false } + override val isFirstRun: Flow = + context.appPreferencesDataStore.data.map { prefs -> + prefs[Keys.IS_FIRST_RUN] ?: true + } + override suspend fun updateNotificationPermissionRequested(requested: Boolean) { context.appPreferencesDataStore.edit { prefs -> prefs[Keys.HAS_REQUESTED_NOTIFICATION_PERMISSION] = requested } } + + override suspend fun updateIsFirstRun(isFirstRun: Boolean) { + context.appPreferencesDataStore.edit { prefs -> + prefs[Keys.IS_FIRST_RUN] = isFirstRun + } + } } diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferences.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferences.kt index e8ac41e7..b9ed508a 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferences.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferences.kt @@ -15,6 +15,7 @@ enum class UserType { */ data class UserPreferences( val displayName: String = "손님", + val profileImageUrl: String = "", val accessToken: String = "", val refreshToken: String = "", val userType: UserType = UserType.GUEST, diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSource.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSource.kt index c0ae9d54..de0416aa 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSource.kt @@ -16,6 +16,8 @@ interface UserPreferencesDataSource { suspend fun updateDisplayName(newName: String) + suspend fun updateProfileImageUrl(newUrl: String) + suspend fun updateTokens( accessToken: String, refreshToken: String, @@ -23,5 +25,5 @@ interface UserPreferencesDataSource { suspend fun updateUserType(userType: UserType) - suspend fun clearTokens() + suspend fun clearUserInfo() } diff --git a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt index cd7afcd4..3aa9899f 100644 --- a/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt +++ b/core/datastore/src/main/java/com/sseotdabwa/buyornot/core/datastore/UserPreferencesDataSourceImpl.kt @@ -25,6 +25,7 @@ class UserPreferencesDataSourceImpl ) : UserPreferencesDataSource { private object Keys { val DISPLAY_NAME = stringPreferencesKey("display_name") + val PROFILE_IMAGE_URL = stringPreferencesKey("profile_image_url") val ACCESS_TOKEN = stringPreferencesKey("access_token") val REFRESH_TOKEN = stringPreferencesKey("refresh_token") val USER_TYPE = stringPreferencesKey("user_type") @@ -34,6 +35,7 @@ class UserPreferencesDataSourceImpl context.userPreferencesDataStore.data.map { prefs -> UserPreferences( displayName = prefs[Keys.DISPLAY_NAME] ?: UserPreferences().displayName, + profileImageUrl = prefs[Keys.PROFILE_IMAGE_URL] ?: UserPreferences().profileImageUrl, accessToken = prefs[Keys.ACCESS_TOKEN] ?: UserPreferences().accessToken, refreshToken = prefs[Keys.REFRESH_TOKEN] ?: UserPreferences().refreshToken, userType = @@ -66,6 +68,12 @@ class UserPreferencesDataSourceImpl } } + override suspend fun updateProfileImageUrl(newUrl: String) { + context.userPreferencesDataStore.edit { prefs -> + prefs[Keys.PROFILE_IMAGE_URL] = newUrl + } + } + override suspend fun updateTokens( accessToken: String, refreshToken: String, @@ -82,10 +90,12 @@ class UserPreferencesDataSourceImpl } } - override suspend fun clearTokens() { + override suspend fun clearUserInfo() { context.userPreferencesDataStore.edit { prefs -> prefs.remove(Keys.ACCESS_TOKEN) prefs.remove(Keys.REFRESH_TOKEN) + prefs.remove(Keys.PROFILE_IMAGE_URL) + prefs.remove(Keys.DISPLAY_NAME) prefs[Keys.USER_TYPE] = UserType.GUEST.name } } diff --git a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt index 6a2ee85c..40c77e3f 100644 --- a/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt +++ b/core/designsystem/src/main/java/com/sseotdabwa/buyornot/core/designsystem/components/FeedCard.kt @@ -72,6 +72,7 @@ fun FeedCard( totalVoteCount: Int, onVote: (Int) -> Unit, // 투표 옵션 인덱스 (0: 사!, 1: 애매..) isOwner: Boolean = false, // 본인 글인지 여부 + voterProfileImageUrl: String = "", // 사용자가 투표한 경우의 프로필 이미지 URL onDeleteClick: () -> Unit = {}, // 삭제 클릭 콜백 추가 onReportClick: () -> Unit = {}, // 신고 클릭 콜백 추가 ) { @@ -265,15 +266,15 @@ fun FeedCard( leadingContent = if (userVotedOptionIndex == 0) { { - Box( + AsyncImage( + model = voterProfileImageUrl, + contentDescription = null, modifier = Modifier .height(20.dp) .width(20.dp) - .background( - color = BuyOrNotTheme.colors.gray500, - shape = RoundedCornerShape(10.dp), - ), + .clip(CircleShape), + contentScale = ContentScale.Crop, ) } } else { @@ -289,15 +290,15 @@ fun FeedCard( leadingContent = if (userVotedOptionIndex == 1) { { - Box( + AsyncImage( + model = voterProfileImageUrl, + contentDescription = null, modifier = Modifier .height(20.dp) .width(20.dp) - .background( - color = BuyOrNotTheme.colors.gray500, - shape = RoundedCornerShape(10.dp), - ), + .clip(CircleShape), + contentScale = ContentScale.Crop, ) } } else { diff --git a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/authenticator/TokenAuthenticator.kt b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/authenticator/TokenAuthenticator.kt index 446550d2..e2cb9a5d 100644 --- a/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/authenticator/TokenAuthenticator.kt +++ b/core/network/src/main/java/com/sseotdabwa/buyornot/core/network/authenticator/TokenAuthenticator.kt @@ -44,7 +44,7 @@ class TokenAuthenticator @Inject constructor( val maxRetries = 2 if (retryCount(response) > maxRetries) { - userPreferencesDataSource.clearTokens() + userPreferencesDataSource.clearUserInfo() authEventBus.emit(AuthEvent.FORCE_LOGOUT) return@runBlocking null } @@ -89,7 +89,7 @@ class TokenAuthenticator @Inject constructor( .header("Authorization", "Bearer ${newTokens.accessToken}") .build() }.getOrElse { - userPreferencesDataSource.clearTokens() + userPreferencesDataSource.clearUserInfo() authEventBus.emit(AuthEvent.FORCE_LOGOUT) null } diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt index 0ca5ede0..c4bbdbb4 100644 --- a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/Feed.kt @@ -6,7 +6,7 @@ package com.sseotdabwa.buyornot.domain.model data class Feed( val feedId: Long, val content: String, - val price: Int, + val price: String, val category: FeedCategory, val yesCount: Int, val noCount: Int, diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/UserPreferences.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/UserPreferences.kt new file mode 100644 index 00000000..c8bfb0cd --- /dev/null +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/UserPreferences.kt @@ -0,0 +1,10 @@ +package com.sseotdabwa.buyornot.domain.model + +/** + * 사용자 정보 도메인 모델 + */ +data class UserPreferences( + val displayName: String, + val profileImageUrl: String, + val userType: UserType, +) diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/UserToken.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/UserToken.kt new file mode 100644 index 00000000..7662153b --- /dev/null +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/model/UserToken.kt @@ -0,0 +1,9 @@ +package com.sseotdabwa.buyornot.domain.model + +/** + * 인증 토큰 정보 도메인 모델 + */ +data class UserToken( + val accessToken: String, + val refreshToken: String, +) diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt index 523241dc..2b0195b5 100644 --- a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AppPreferencesRepository.kt @@ -13,8 +13,18 @@ interface AppPreferencesRepository { */ val hasRequestedNotificationPermission: Flow + /** + * 최초 실행 여부를 Flow로 제공 + */ + val isFirstRun: Flow + /** * 알림 권한 요청 이력 업데이트 */ suspend fun updateNotificationPermissionRequested(requested: Boolean) + + /** + * 최초 실행 여부 업데이트 + */ + suspend fun updateIsFirstRun(isFirstRun: Boolean) } diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AuthRepository.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AuthRepository.kt index eb4c6e15..1f08aa6c 100644 --- a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AuthRepository.kt +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/AuthRepository.kt @@ -7,5 +7,5 @@ interface AuthRepository { suspend fun logout() - suspend fun clearTokens() + suspend fun clearUserInfo() } diff --git a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/UserPreferencesRepository.kt b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/UserPreferencesRepository.kt index f993cfed..aea959df 100644 --- a/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/UserPreferencesRepository.kt +++ b/domain/src/main/java/com/sseotdabwa/buyornot/domain/repository/UserPreferencesRepository.kt @@ -1,5 +1,7 @@ package com.sseotdabwa.buyornot.domain.repository +import com.sseotdabwa.buyornot.domain.model.UserPreferences +import com.sseotdabwa.buyornot.domain.model.UserToken import com.sseotdabwa.buyornot.domain.model.UserType import kotlinx.coroutines.flow.Flow @@ -7,6 +9,16 @@ import kotlinx.coroutines.flow.Flow * 사용자 정보 Repository */ interface UserPreferencesRepository { + /** + * 전체 사용자 정보를 Flow로 제공 + */ + val userPreferences: Flow + + /** + * 인증 토큰 정보를 Flow로 제공 + */ + val userToken: Flow + /** * 현재 사용자 타입을 Flow로 제공 */ @@ -16,4 +28,14 @@ interface UserPreferencesRepository { * 사용자 타입 업데이트 */ suspend fun updateUserType(userType: UserType) + + /** + * 표시 이름 업데이트 + */ + suspend fun updateDisplayName(newName: String) + + /** + * 프로필 이미지 URL 업데이트 + */ + suspend fun updateProfileImageUrl(newUrl: String) } diff --git a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginViewModel.kt b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginViewModel.kt index 8ecdee88..bc800fb0 100644 --- a/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginViewModel.kt +++ b/feature/auth/src/main/java/com/sseotdabwa/buyornot/feature/auth/ui/LoginViewModel.kt @@ -23,6 +23,7 @@ import com.sseotdabwa.buyornot.domain.repository.UserRepository import com.sseotdabwa.buyornot.feature.auth.R import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await import java.security.SecureRandom import java.util.Base64 import javax.inject.Inject @@ -101,6 +102,7 @@ class LoginViewModel @Inject constructor( runCatchingCancellable { authRepository.googleLogin(idToken) }.onSuccess { + fetchAndStoreUserProfile() updateFcmToken() sendSideEffect(LoginSideEffect.NavigateToHome) }.onFailure { @@ -157,6 +159,7 @@ class LoginViewModel @Inject constructor( runCatchingCancellable { authRepository.kakaoLogin(accessToken) }.onSuccess { + fetchAndStoreUserProfile() updateFcmToken() sendSideEffect(LoginSideEffect.NavigateToHome) }.onFailure { @@ -166,26 +169,26 @@ class LoginViewModel @Inject constructor( } } + private suspend fun fetchAndStoreUserProfile() { + runCatchingCancellable { + userRepository.getMyProfile() + }.onSuccess { profile -> + userPreferencesRepository.updateDisplayName(profile.nickname) + userPreferencesRepository.updateProfileImageUrl(profile.profileImage) + }.onFailure { e -> + Log.e(TAG, "Failed to fetch user profile after login", e) + } + } + /** * FCM 토큰을 가져와 서버에 업데이트합니다. */ - private fun updateFcmToken() { - FirebaseMessaging.getInstance().token.addOnCompleteListener { task -> - if (!task.isSuccessful) { - Log.w("FCM", "Fetching FCM registration token failed", task.exception) - return@addOnCompleteListener - } - - val token = task.result ?: return@addOnCompleteListener - viewModelScope.launch { - runCatchingCancellable { - userRepository.updateFcmToken(token) - }.onSuccess { - Log.d("FCM", "FCM Token successfully updated to server.") - }.onFailure { e -> - Log.e("FCM", "Failed to update FCM token to server", e) - } - } + private suspend fun updateFcmToken() { + runCatchingCancellable { + val token = FirebaseMessaging.getInstance().token.await() + userRepository.updateFcmToken(token) + }.onFailure { + Log.e("FCM", "Failed to update FCM token to server", it) } } diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt index be401504..68187740 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/navigation/HomeNavigation.kt @@ -7,7 +7,7 @@ import androidx.navigation.NavType import androidx.navigation.compose.composable import androidx.navigation.navArgument import com.sseotdabwa.buyornot.feature.home.ui.HomeRoute -import com.sseotdabwa.buyornot.feature.home.viewmodel.HomeTab +import com.sseotdabwa.buyornot.feature.home.ui.HomeTab const val HOME_ROUTE = "home" const val HOME_ROUTE_WITH_TAB = "home?tab={tab}" diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/viewmodel/HomeContract.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt similarity index 97% rename from feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/viewmodel/HomeContract.kt rename to feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt index 5f71b6f1..a974a374 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/viewmodel/HomeContract.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeContract.kt @@ -1,4 +1,4 @@ -package com.sseotdabwa.buyornot.feature.home.viewmodel +package com.sseotdabwa.buyornot.feature.home.ui import androidx.compose.runtime.Immutable import com.sseotdabwa.buyornot.core.designsystem.components.ImageAspectRatio @@ -63,6 +63,7 @@ data class HomeUiState( val selectedTab: HomeTab = HomeTab.FEED, val selectedFilter: FilterChip = FilterChip.ALL, val isBannerVisible: Boolean = true, + val voterProfileImageUrl: String = "", val feeds: List = emptyList(), val isLoading: Boolean = true, val isNextPageLoading: Boolean = false, diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt index 77465420..acc995c6 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt @@ -63,12 +63,6 @@ import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.designsystem.icon.asImageVector import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.domain.model.UserType -import com.sseotdabwa.buyornot.feature.home.viewmodel.FeedItem -import com.sseotdabwa.buyornot.feature.home.viewmodel.FilterChip -import com.sseotdabwa.buyornot.feature.home.viewmodel.HomeIntent -import com.sseotdabwa.buyornot.feature.home.viewmodel.HomeSideEffect -import com.sseotdabwa.buyornot.feature.home.viewmodel.HomeTab -import com.sseotdabwa.buyornot.feature.home.viewmodel.HomeUiState import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlin.math.roundToInt @@ -482,6 +476,7 @@ private fun HomeFeedList( items(filteredFeeds.size, key = { index -> filteredFeeds[index].id }) { index -> FeedItemCard( feed = filteredFeeds[index], + voterProfileImageUrl = uiState.voterProfileImageUrl, modifier = Modifier.padding(20.dp).animateItem(), onVote = { id, opt -> onIntent(HomeIntent.OnVoteClicked(id, opt)) }, onDelete = { id -> onIntent(HomeIntent.OnDeleteClicked(id)) }, @@ -571,6 +566,7 @@ private fun FilterChipRow( @Composable private fun FeedItemCard( feed: FeedItem, + voterProfileImageUrl: String, modifier: Modifier = Modifier, onVote: (String, Int) -> Unit, onDelete: (String) -> Unit, @@ -593,6 +589,7 @@ private fun FeedItemCard( maybeVoteCount = feed.maybeVoteCount, totalVoteCount = feed.totalVoteCount, isOwner = feed.isOwner, + voterProfileImageUrl = voterProfileImageUrl, onVote = { option -> onVote(feed.id, option) }, diff --git a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt index 6ebf3a19..e892e96a 100644 --- a/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt +++ b/feature/home/src/main/java/com/sseotdabwa/buyornot/feature/home/ui/HomeViewModel.kt @@ -14,15 +14,7 @@ import com.sseotdabwa.buyornot.domain.model.VoteChoice import com.sseotdabwa.buyornot.domain.repository.FeedRepository import com.sseotdabwa.buyornot.domain.repository.UserPreferencesRepository import com.sseotdabwa.buyornot.domain.repository.UserRepository -import com.sseotdabwa.buyornot.feature.home.viewmodel.FeedItem -import com.sseotdabwa.buyornot.feature.home.viewmodel.FilterChip -import com.sseotdabwa.buyornot.feature.home.viewmodel.HomeIntent -import com.sseotdabwa.buyornot.feature.home.viewmodel.HomeSideEffect -import com.sseotdabwa.buyornot.feature.home.viewmodel.HomeTab -import com.sseotdabwa.buyornot.feature.home.viewmodel.HomeUiState import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -41,13 +33,41 @@ class HomeViewModel @Inject constructor( private var isUserIdLoaded = false // ID 로드 완료 여부 추적 init { - observeUserType() + observeUserPreferences() loadInitialData() } + private fun observeUserPreferences() { + viewModelScope.launch { + var lastUserType: UserType? = null + userPreferencesRepository.userPreferences + .collect { preferences -> + val userType = preferences.userType + updateState { + it.copy( + userType = userType, + voterProfileImageUrl = preferences.profileImageUrl, + ) + } + + if (lastUserType != userType) { + if (userType == UserType.SOCIAL) { + loadUserIdAndRefreshFeeds() + } else { + currentUserId = null + isUserIdLoaded = true + // 게스트 전환 시 탭을 무조건 FEED로 변경 + updateState { it.copy(selectedTab = HomeTab.FEED) } + loadFeeds(HomeTab.FEED) + } + lastUserType = userType + } + } + } + } + /** * 초기 데이터 로드: 사용자 ID를 먼저 로드한 후 피드 로드 - * 경쟁 조건을 방지하기 위해 순차적으로 실행 */ private fun loadInitialData() { viewModelScope.launch { @@ -65,7 +85,10 @@ class HomeViewModel @Inject constructor( private suspend fun loadCurrentUserIdSuspend() { runCatchingCancellable { if (uiState.value.userType == UserType.SOCIAL) { - userRepository.getMyProfile().id + val profile = userRepository.getMyProfile() + userPreferencesRepository.updateDisplayName(profile.nickname) + userPreferencesRepository.updateProfileImageUrl(profile.profileImage) + profile.id } else { null } @@ -77,33 +100,6 @@ class HomeViewModel @Inject constructor( } } - private fun observeUserType() { - viewModelScope.launch { - userPreferencesRepository.userType - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(5000), - initialValue = UserType.GUEST, - ).collect { userType -> - updateState { it.copy(userType = userType) } - // 사용자 타입이 변경되면 userId를 다시 로드하고 피드도 갱신 - if (userType == UserType.SOCIAL) { - loadUserIdAndRefreshFeeds() - } else { - currentUserId = null - isUserIdLoaded = true - // 게스트 전환 시 탭을 무조건 FEED로 변경 - updateState { it.copy(selectedTab = HomeTab.FEED) } - loadFeeds(HomeTab.FEED) - } - } - } - } - - /** - * 사용자 ID를 로드하고 피드를 갱신 - * 로그인 후 자신의 피드에 대한 isOwner를 올바르게 설정 - */ private fun loadUserIdAndRefreshFeeds() { viewModelScope.launch { loadCurrentUserIdSuspend() @@ -210,6 +206,18 @@ class HomeViewModel @Inject constructor( val previousFeeds = uiState.value.feeds val previousCachedFeeds = cachedFeeds + // 본인 글 여부 확인 (투표 방지) + val targetFeed = cachedFeeds.find { it.id == feedId } + if (targetFeed?.isOwner == true) { + sendSideEffect( + HomeSideEffect.ShowSnackbar( + message = "자신의 글에는 투표할 수 없습니다.", + icon = null, + ), + ) + return + } + // 1. 낙관적 업데이트 (Optimistic Update) // API 호출 전 UI를 즉시 업데이트하여 사용자 경험 개선 val optimisticUpdate = { feeds: List -> @@ -436,7 +444,7 @@ class HomeViewModel @Inject constructor( createdAt = TimeUtils.formatRelativeTime(createdAt), content = content, productImageUrl = viewUrl, - price = price.toString(), + price = price, imageAspectRatio = aspectRatio, isVoteEnded = feedStatus == FeedStatus.CLOSED, userVotedOptionIndex = diff --git a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/AccountSettingViewModel.kt b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/AccountSettingViewModel.kt index 2fbbdb3c..3e0fae8b 100644 --- a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/AccountSettingViewModel.kt +++ b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/AccountSettingViewModel.kt @@ -90,7 +90,7 @@ class AccountSettingViewModel @Inject constructor( // 2. (가장 중요) 어떤 경우든 로컬 토큰은 반드시 삭제 withContext(NonCancellable) { runCatchingCancellable { - authRepository.clearTokens() + authRepository.clearUserInfo() } } diff --git a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/MyPageViewModel.kt b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/MyPageViewModel.kt index cc391b59..4e001d9e 100644 --- a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/MyPageViewModel.kt +++ b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/MyPageViewModel.kt @@ -1,16 +1,21 @@ package com.sseotdabwa.buyornot.feature.mypage.viewmodel +import android.util.Log import androidx.lifecycle.viewModelScope import com.sseotdabwa.buyornot.core.common.util.runCatchingCancellable import com.sseotdabwa.buyornot.core.ui.base.BaseViewModel +import com.sseotdabwa.buyornot.domain.repository.UserPreferencesRepository import com.sseotdabwa.buyornot.domain.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "MyPageViewModel" + @HiltViewModel class MyPageViewModel @Inject constructor( private val userRepository: UserRepository, + private val userPreferencesRepository: UserPreferencesRepository, ) : BaseViewModel(MyPageUiState()) { init { handleIntent(MyPageIntent.LoadProfile) @@ -29,9 +34,16 @@ class MyPageViewModel @Inject constructor( userRepository.getMyProfile() }.onSuccess { profile -> updateState { it.copy(isLoading = false, userProfile = profile) } + runCatchingCancellable { + userPreferencesRepository.updateDisplayName(profile.nickname) + userPreferencesRepository.updateProfileImageUrl(profile.profileImage) + }.onFailure { + Log.w(TAG, "Failed to update user preferences") + } }.onFailure { throwable -> updateState { it.copy(isLoading = false) } - sendSideEffect(MyPageSideEffect.ShowSnackbar(throwable.message ?: "프로필을 불러오지 못했습니다.")) + sendSideEffect(MyPageSideEffect.ShowSnackbar("프로필을 불러오지 못했습니다.")) + Log.w(TAG, throwable.toString()) } } } diff --git a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/WithdrawalViewModel.kt b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/WithdrawalViewModel.kt index a0ef2c57..c10659e1 100644 --- a/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/WithdrawalViewModel.kt +++ b/feature/mypage/src/main/java/com/sseotdabwa/buyornot/feature/mypage/viewmodel/WithdrawalViewModel.kt @@ -89,7 +89,7 @@ class WithdrawalViewModel @Inject constructor( // 3. 성공/실패 여부와 관계없이 항상 로컬 토큰을 삭제하고 로그인 화면으로 이동 withContext(NonCancellable) { runCatchingCancellable { - authRepository.clearTokens() + authRepository.clearUserInfo() } } diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailContract.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailContract.kt index 29034272..e9bc772b 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailContract.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailContract.kt @@ -1,6 +1,7 @@ package com.sseotdabwa.buyornot.feature.notification.ui import androidx.compose.runtime.Immutable +import com.sseotdabwa.buyornot.core.designsystem.icon.IconResource import com.sseotdabwa.buyornot.domain.model.Feed @Immutable @@ -8,10 +9,23 @@ data class NotificationDetailUiState( val isLoading: Boolean = true, val isError: Boolean = false, val feed: Feed? = null, + val voterProfileImageUrl: String = "", + val isOwner: Boolean = false, ) sealed interface NotificationDetailIntent { data object OnRefresh : NotificationDetailIntent + + data object OnDeleteClicked : NotificationDetailIntent + + data object OnReportClicked : NotificationDetailIntent } -sealed interface NotificationDetailSideEffect +sealed interface NotificationDetailSideEffect { + data class ShowSnackbar( + val message: String, + val icon: IconResource? = null, + ) : NotificationDetailSideEffect + + data object NavigateBack : NotificationDetailSideEffect +} diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt index a2c1e8e2..00238e05 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailScreen.kt @@ -1,6 +1,5 @@ package com.sseotdabwa.buyornot.feature.notification.ui -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize @@ -8,8 +7,12 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHostState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview @@ -19,8 +22,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sseotdabwa.buyornot.core.common.util.TimeUtils import com.sseotdabwa.buyornot.core.designsystem.components.BackTopBar import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotErrorView +import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotSnackBarHost import com.sseotdabwa.buyornot.core.designsystem.components.FeedCard import com.sseotdabwa.buyornot.core.designsystem.components.ImageAspectRatio +import com.sseotdabwa.buyornot.core.designsystem.components.showBuyOrNotSnackBar import com.sseotdabwa.buyornot.core.designsystem.theme.BuyOrNotTheme import com.sseotdabwa.buyornot.domain.model.Author import com.sseotdabwa.buyornot.domain.model.Feed @@ -43,9 +48,27 @@ fun NotificationDetailRoute( viewModel: NotificationDetailViewModel = hiltViewModel(), ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + + // SideEffect 처리 + LaunchedEffect(Unit) { + viewModel.sideEffect.collect { sideEffect -> + when (sideEffect) { + is NotificationDetailSideEffect.ShowSnackbar -> { + showBuyOrNotSnackBar( + snackbarHostState = snackbarHostState, + message = sideEffect.message, + iconResource = sideEffect.icon, + ) + } + NotificationDetailSideEffect.NavigateBack -> onBackClick() + } + } + } NotificationDetailScreen( uiState = uiState, + snackbarHostState = snackbarHostState, onBackClick = onBackClick, onIntent = viewModel::handleIntent, ) @@ -56,16 +79,19 @@ fun NotificationDetailScreen( uiState: NotificationDetailUiState, onBackClick: () -> Unit, onIntent: (NotificationDetailIntent) -> Unit, + snackbarHostState: SnackbarHostState = remember { SnackbarHostState() }, ) { - Box(modifier = Modifier.fillMaxSize()) { - Column( + Scaffold( + snackbarHost = { BuyOrNotSnackBarHost(snackbarHostState) }, + topBar = { BackTopBar(onBackClick = onBackClick) }, + containerColor = BuyOrNotTheme.colors.gray0, + ) { innerPadding -> + Box( modifier = Modifier .fillMaxSize() - .background(BuyOrNotTheme.colors.gray0), + .padding(innerPadding), ) { - BackTopBar(onBackClick = onBackClick) - when { uiState.isLoading -> { Box( @@ -100,7 +126,7 @@ fun NotificationDetailScreen( createdAt = TimeUtils.formatRelativeTime(feed.createdAt), content = feed.content, productImageUrl = feed.viewUrl, - price = String.format(java.util.Locale.KOREA, "%,d", feed.price), + price = feed.price, imageAspectRatio = if (feed.imageWidth > 0 && feed.imageHeight > 0) { if (feed.imageHeight > feed.imageWidth) ImageAspectRatio.PORTRAIT else ImageAspectRatio.SQUARE @@ -117,7 +143,11 @@ fun NotificationDetailScreen( buyVoteCount = feed.yesCount, maybeVoteCount = feed.noCount, totalVoteCount = feed.totalCount, + isOwner = uiState.isOwner, + voterProfileImageUrl = uiState.voterProfileImageUrl, onVote = { /* 이미 종료된 투표이기 때문에 투표 기능 미구현 */ }, + onDeleteClick = { onIntent(NotificationDetailIntent.OnDeleteClicked) }, + onReportClick = { onIntent(NotificationDetailIntent.OnReportClicked) }, ) } } @@ -138,7 +168,7 @@ private fun NotificationDetailScreenPreview() { Feed( feedId = 1L, content = "이거 어때요? 투표 결과가 궁금해요!", - price = 35000, + price = "35,000", category = FeedCategory.BOOK, yesCount = 80, noCount = 20, diff --git a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailViewModel.kt b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailViewModel.kt index b741855f..b70ee181 100644 --- a/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailViewModel.kt +++ b/feature/notification/src/main/java/com/sseotdabwa/buyornot/feature/notification/ui/NotificationDetailViewModel.kt @@ -1,34 +1,58 @@ package com.sseotdabwa.buyornot.feature.notification.ui +import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.sseotdabwa.buyornot.core.common.util.runCatchingCancellable +import com.sseotdabwa.buyornot.core.designsystem.icon.BuyOrNotIcons import com.sseotdabwa.buyornot.core.ui.base.BaseViewModel import com.sseotdabwa.buyornot.domain.repository.FeedRepository import com.sseotdabwa.buyornot.domain.repository.NotificationRepository +import com.sseotdabwa.buyornot.domain.repository.UserPreferencesRepository +import com.sseotdabwa.buyornot.domain.repository.UserRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch import javax.inject.Inject +private const val TAG = "NotificationDetailViewModel" + @HiltViewModel class NotificationDetailViewModel @Inject constructor( private val savedStateHandle: SavedStateHandle, private val feedRepository: FeedRepository, private val notificationRepository: NotificationRepository, + private val userRepository: UserRepository, + private val userPreferencesRepository: UserPreferencesRepository, ) : BaseViewModel( NotificationDetailUiState(), ) { private val notificationId: Long = checkNotNull(savedStateHandle["notificationId"]) private val feedId: Long = checkNotNull(savedStateHandle["feedId"]) + private var currentUserId: Long? = null + init { + observeUserPreferences() loadDetail() markAsRead() } + private fun observeUserPreferences() { + viewModelScope.launch { + userPreferencesRepository.userPreferences + .collect { preferences -> + updateState { + it.copy(voterProfileImageUrl = preferences.profileImageUrl) + } + } + } + } + override fun handleIntent(intent: NotificationDetailIntent) { when (intent) { NotificationDetailIntent.OnRefresh -> loadDetail() + NotificationDetailIntent.OnDeleteClicked -> handleDelete() + NotificationDetailIntent.OnReportClicked -> handleReport() } } @@ -36,15 +60,83 @@ class NotificationDetailViewModel @Inject constructor( viewModelScope.launch { updateState { it.copy(isLoading = true, isError = false) } runCatchingCancellable { + if (currentUserId == null) { + runCatchingCancellable { + userRepository.getMyProfile().id + }.onSuccess { id -> + currentUserId = id + }.onFailure { + Log.w(TAG, "Failed to get current user ID") + } + } feedRepository.getFeed(feedId) }.onSuccess { feed -> - updateState { it.copy(isLoading = false, feed = feed) } + val isOwner = currentUserId != null && feed.author.userId == currentUserId + updateState { + it.copy( + isLoading = false, + feed = feed, + isOwner = isOwner, + ) + } }.onFailure { updateState { it.copy(isLoading = false, isError = true) } } } } + private fun handleDelete() { + viewModelScope.launch { + runCatchingCancellable { + feedRepository.deleteFeed(feedId) + }.onSuccess { + sendSideEffect( + NotificationDetailSideEffect.ShowSnackbar( + message = "삭제가 완료되었습니다.", + icon = BuyOrNotIcons.CheckCircle, + ), + ) + sendSideEffect(NotificationDetailSideEffect.NavigateBack) + }.onFailure { e -> + Log.e(TAG, "Failed to delete feed: $feedId", e) + sendSideEffect( + NotificationDetailSideEffect.ShowSnackbar( + message = "삭제에 실패했습니다.", + icon = null, + ), + ) + } + } + } + + private fun handleReport() { + viewModelScope.launch { + runCatchingCancellable { + feedRepository.reportFeed(feedId) + }.onSuccess { + sendSideEffect( + NotificationDetailSideEffect.ShowSnackbar( + message = "신고가 완료되었습니다.", + icon = BuyOrNotIcons.CheckCircle, + ), + ) + }.onFailure { e -> + Log.e("NotificationDetailViewModel", "Failed to report feed: $feedId", e) + val errorMessage = + when { + e.message?.contains("400") == true -> "이미 신고한 피드이거나 본인의 피드입니다." + else -> "신고에 실패했습니다." + } + sendSideEffect( + NotificationDetailSideEffect.ShowSnackbar( + message = errorMessage, + icon = null, + ), + ) + } + } + } + private fun markAsRead() { viewModelScope.launch { runCatchingCancellable { diff --git a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt index c04db261..51b2d2d4 100644 --- a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt +++ b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadContract.kt @@ -15,7 +15,10 @@ data class UploadUiState( val showCategorySheet: Boolean = false, val showExitDialog: Boolean = false, val categories: List = FeedCategory.entries, -) +) { + val hasInput: Boolean + get() = selectedImageUri != null || category != null || price.isNotEmpty() || content.isNotEmpty() +} sealed interface UploadIntent { data class UpdateCategory( diff --git a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadScreen.kt b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadScreen.kt index 783e7692..665ae09b 100644 --- a/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadScreen.kt +++ b/feature/upload/src/main/java/com/sseotdabwa/buyornot/feature/upload/ui/UploadScreen.kt @@ -119,7 +119,11 @@ fun UploadScreen( } BackHandler { - if (!uiState.showExitDialog) viewModel.handleIntent(UploadIntent.UpdateExitDialogVisibility(true)) + if (uiState.hasInput) { + if (!uiState.showExitDialog) viewModel.handleIntent(UploadIntent.UpdateExitDialogVisibility(true)) + } else { + onNavigateBack() + } } Column( @@ -131,7 +135,11 @@ fun UploadScreen( .windowInsetsPadding(WindowInsets.safeDrawing), ) { BackTopBar { - viewModel.handleIntent(UploadIntent.UpdateExitDialogVisibility(true)) + if (uiState.hasInput) { + viewModel.handleIntent(UploadIntent.UpdateExitDialogVisibility(true)) + } else { + onNavigateBack() + } } Column( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index cf28deaa..ad3dea6b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,8 +41,9 @@ googleid = "1.2.0" lottie = "6.7.1" # Firebase -firebaseBom = "33.9.0" +firebaseBom = "34.9.0" googleServices = "4.4.2" +firebaseCrashlytics = "3.0.3" # Code Quality ktlint = "14.0.1" @@ -120,6 +121,7 @@ androidx-compose-runtime = { group = "androidx.compose.runtime", name = "runtime # Firebase firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } lottie-compose = { group = "com.airbnb.android", name = "lottie-compose", version.ref = "lottie" } @@ -134,3 +136,4 @@ hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } google-services = { id = "com.google.gms.google-services", version.ref = "googleServices" } +firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlytics" }