diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 173384ab..32ccc533 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,8 +30,8 @@ android { defaultConfig { applicationId = "com.sseotdabwa.buyornot" - versionCode = 7 - versionName = "0.3.1" + versionCode = 8 + versionName = "0.3.2" buildConfigField("String", "KAKAO_NATIVE_APP_KEY", "\"${localProperties.getProperty("kakao.nativeAppKey", "")}\"") manifestPlaceholders["NATIVE_APP_KEY"] = localProperties.getProperty("kakao.nativeAppKey", "") diff --git a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt index 3035439a..129ad4e0 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt @@ -1,10 +1,16 @@ package com.sseotdabwa.buyornot.ui +import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.sseotdabwa.buyornot.core.analytics.Analytics import com.sseotdabwa.buyornot.domain.repository.AppPreferencesRepository +import com.sseotdabwa.buyornot.domain.repository.UserPreferencesRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -12,6 +18,8 @@ import javax.inject.Inject @HiltViewModel class BuyOrNotViewModel @Inject constructor( private val appPreferencesRepository: AppPreferencesRepository, + private val userPreferencesRepository: UserPreferencesRepository, + private val analytics: Analytics, ) : ViewModel() { val isFirstRun = appPreferencesRepository.isFirstRun @@ -21,6 +29,15 @@ class BuyOrNotViewModel @Inject constructor( initialValue = false, ) + init { + userPreferencesRepository.userId + .distinctUntilChanged() + .onEach { userId -> + Log.d("BuyOrNotViewModel", "userId: $userId") + analytics.identify(if (userId != 0L) userId.toString() else null) + }.launchIn(viewModelScope) + } + fun updateIsFirstRun(isFirstRun: Boolean) { viewModelScope.launch { appPreferencesRepository.updateIsFirstRun(isFirstRun) diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt index 5c22ae9b..94781073 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/Analytics.kt @@ -2,4 +2,6 @@ package com.sseotdabwa.buyornot.core.analytics interface Analytics { fun track(event: AnalyticsEvent) + + fun identify(userId: String?) } diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt index e14a5910..cd3eb1b9 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/DebugAnalytics.kt @@ -2,8 +2,18 @@ package com.sseotdabwa.buyornot.core.analytics import android.util.Log -class DebugAnalytics : Analytics { +class DebugAnalytics( + private val appVersion: String, +) : Analytics { + private var userId: String? = null + override fun track(event: AnalyticsEvent) { - Log.d("Analytics", event.toString()) + val superProps = "platform=android, app_version=$appVersion, user_id=$userId" + Log.d("Analytics", "$event [$superProps]") + } + + override fun identify(userId: String?) { + this.userId = userId + Log.d("Analytics", "identify: userId=$userId") } } diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt index 792777cc..83458a15 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/MixpanelAnalytics.kt @@ -6,12 +6,32 @@ import org.json.JSONObject class MixpanelAnalytics( private val mixpanel: MixpanelAPI, + appVersion: String, ) : Analytics { + init { + mixpanel.registerSuperProperties( + JSONObject().apply { + put("platform", "android") + put("app_version", appVersion) + put("user_id", JSONObject.NULL) + }, + ) + } + override fun track(event: AnalyticsEvent) { val (name, props) = event.toMixpanel() mixpanel.track(name, props) } + override fun identify(userId: String?) { + if (userId != null) { + mixpanel.identify(userId) + } + mixpanel.registerSuperProperties( + JSONObject().apply { put("user_id", userId ?: JSONObject.NULL) }, + ) + } + private fun AnalyticsEvent.toMixpanel(): Pair { val props = JSONObject() val name = diff --git a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt index 591cc443..5c92d814 100644 --- a/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt +++ b/core/analytics/src/main/java/com/sseotdabwa/buyornot/core/analytics/di/AnalyticsModule.kt @@ -20,9 +20,14 @@ object AnalyticsModule { @Singleton fun provideAnalytics( @ApplicationContext context: Context, - ): Analytics = - if (BuildConfig.DEBUG) { - DebugAnalytics() + ): Analytics { + val appVersion = + context.packageManager + .getPackageInfo(context.packageName, 0) + .versionName + ?: "unknown" + return if (BuildConfig.DEBUG) { + DebugAnalytics(appVersion) } else { val mixpanel = MixpanelAPI.getInstance( @@ -30,6 +35,12 @@ object AnalyticsModule { BuildConfig.MIXPANEL_TOKEN, true, ) - MixpanelAnalytics(mixpanel) + val appVersion = + context.packageManager + .getPackageInfo(context.packageName, 0) + .versionName + ?: "unknown" + MixpanelAnalytics(mixpanel, appVersion) } + } } 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 174a70f0..01430584 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 @@ -27,10 +27,16 @@ class UserPreferencesRepositoryImpl @Inject constructor( override val userType: Flow = userPreferencesDataSource.userType.map { it.toDomain() } + override val userId: Flow = userPreferencesDataSource.userId + override suspend fun updateUserType(userType: UserType) { userPreferencesDataSource.updateUserType(userType.toDatastore()) } + override suspend fun updateUserId(userId: Long) { + userPreferencesDataSource.updateUserId(userId) + } + override suspend fun updateDisplayName(newName: String) { userPreferencesDataSource.updateDisplayName(newName) } 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 b9ed508a..cbe66c9a 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 @@ -14,6 +14,7 @@ enum class UserType { * 사용자 프로필 및 인증 토큰을 관리합니다. */ data class UserPreferences( + val userId: Long = 0L, val displayName: String = "손님", val profileImageUrl: String = "", val accessToken: String = "", 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 de0416aa..4dee8de2 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 @@ -14,6 +14,10 @@ interface UserPreferencesDataSource { val userType: Flow + val userId: Flow + + suspend fun updateUserId(userId: Long) + suspend fun updateDisplayName(newName: String) suspend fun updateProfileImageUrl(newUrl: String) 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 0b909d40..d76f8dba 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 @@ -2,6 +2,7 @@ package com.sseotdabwa.buyornot.core.datastore import android.content.Context import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import dagger.hilt.android.qualifiers.ApplicationContext @@ -22,6 +23,7 @@ class UserPreferencesDataSourceImpl @Inject constructor( @ApplicationContext private val context: Context, ) : UserPreferencesDataSource { private object Keys { + val USER_ID = longPreferencesKey("user_id") val DISPLAY_NAME = stringPreferencesKey("display_name") val PROFILE_IMAGE_URL = stringPreferencesKey("profile_image_url") val ACCESS_TOKEN = stringPreferencesKey("access_token") @@ -32,6 +34,7 @@ class UserPreferencesDataSourceImpl @Inject constructor( override val preferences: Flow = context.userPreferencesDataStore.data.map { prefs -> UserPreferences( + userId = prefs[Keys.USER_ID] ?: UserPreferences().userId, displayName = prefs[Keys.DISPLAY_NAME] ?: UserPreferences().displayName, profileImageUrl = prefs[Keys.PROFILE_IMAGE_URL] ?: UserPreferences().profileImageUrl, accessToken = prefs[Keys.ACCESS_TOKEN] ?: UserPreferences().accessToken, @@ -47,6 +50,9 @@ class UserPreferencesDataSourceImpl @Inject constructor( ) } + override val userId: Flow = + context.userPreferencesDataStore.data.map { it[Keys.USER_ID] ?: 0L } + override val accessToken: Flow = context.userPreferencesDataStore.data.map { it[Keys.ACCESS_TOKEN] ?: "" } override val userType: Flow = @@ -60,6 +66,12 @@ class UserPreferencesDataSourceImpl @Inject constructor( } ?: UserType.GUEST } + override suspend fun updateUserId(userId: Long) { + context.userPreferencesDataStore.edit { prefs -> + prefs[Keys.USER_ID] = userId + } + } + override suspend fun updateDisplayName(newName: String) { context.userPreferencesDataStore.edit { prefs -> prefs[Keys.DISPLAY_NAME] = newName @@ -90,6 +102,7 @@ class UserPreferencesDataSourceImpl @Inject constructor( override suspend fun clearUserInfo() { context.userPreferencesDataStore.edit { prefs -> + prefs.remove(Keys.USER_ID) prefs.remove(Keys.ACCESS_TOKEN) prefs.remove(Keys.REFRESH_TOKEN) prefs.remove(Keys.PROFILE_IMAGE_URL) 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 aea959df..76334535 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 @@ -24,11 +24,21 @@ interface UserPreferencesRepository { */ val userType: Flow + /** + * 현재 로그인된 사용자 ID를 Flow로 제공 (비로그인 시 0L) + */ + val userId: Flow + /** * 사용자 타입 업데이트 */ suspend fun updateUserType(userType: UserType) + /** + * 사용자 ID 업데이트 + */ + suspend fun updateUserId(userId: Long) + /** * 표시 이름 업데이트 */ 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 bc800fb0..979f8977 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 @@ -173,6 +173,7 @@ class LoginViewModel @Inject constructor( runCatchingCancellable { userRepository.getMyProfile() }.onSuccess { profile -> + userPreferencesRepository.updateUserId(profile.id) userPreferencesRepository.updateDisplayName(profile.nickname) userPreferencesRepository.updateProfileImageUrl(profile.profileImage) }.onFailure { e -> 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 6a293e42..2a93fdfb 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 @@ -34,11 +34,11 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -60,6 +60,8 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.sseotdabwa.buyornot.core.designsystem.components.ButtonSize import com.sseotdabwa.buyornot.core.designsystem.components.BuyOrNotAlertDialog @@ -365,17 +367,22 @@ private fun HomeFeedList( val isEmptyViewVisible = filteredFeeds.isEmpty() && !uiState.isLoading && !uiState.hasError val isMyFeedEmpty = uiState.selectedTab == HomeTab.MY_FEED && isEmptyViewVisible - val enterTimeMs = remember { System.currentTimeMillis() } - DisposableEffect(Unit) { + var enterTimeMs by remember { mutableLongStateOf(System.currentTimeMillis()) } + LifecycleEventEffect(Lifecycle.Event.ON_START) { + enterTimeMs = System.currentTimeMillis() onIntent(HomeIntent.OnFeedScreenEntered(firstVisibleItemIndex = listState.firstVisibleItemIndex)) - onDispose { - onIntent( - HomeIntent.OnFeedScreenExited( - lastVisibleItemIndex = listState.firstVisibleItemIndex, - timeSpentSeconds = (System.currentTimeMillis() - enterTimeMs) / 1000f, - ), - ) - } + } + LifecycleEventEffect(Lifecycle.Event.ON_STOP) { + onIntent( + HomeIntent.OnFeedScreenExited( + lastVisibleItemIndex = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index + ?: listState.firstVisibleItemIndex, + timeSpentSeconds = (System.currentTimeMillis() - enterTimeMs) / 1000f, + ), + ) } var headerHeightPx by remember { mutableIntStateOf(0) } val density = LocalDensity.current