From 776760d0df3b8c9c46caa59956884ada4a9397d6 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Mon, 11 May 2026 22:19:05 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat/#111:=20platform,=20app=5Fversion,=20u?= =?UTF-8?q?ser=5Fid=20=EC=8A=88=ED=8D=BC=20=EC=86=8D=EC=84=B1=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Analytics 인터페이스에 identify(userId: String?) 추가 - MixpanelAnalytics 초기화 시 platform, app_version, user_id(null) 슈퍼 속성 등록 - DataStore에 userId(Long) 저장 레이어 추가 (datastore → repository) - 로그인 후 fetchAndStoreUserProfile()에서 userId DataStore 저장 - BuyOrNotViewModel에서 userId 변화 감지 → analytics.identify() 호출 --- .../buyornot/ui/BuyOrNotViewModel.kt | 15 ++++++++++++++ .../buyornot/core/analytics/Analytics.kt | 2 ++ .../buyornot/core/analytics/DebugAnalytics.kt | 4 ++++ .../core/analytics/MixpanelAnalytics.kt | 20 +++++++++++++++++++ .../core/analytics/di/AnalyticsModule.kt | 7 ++++++- .../UserPreferencesRepositoryImpl.kt | 6 ++++++ .../core/datastore/UserPreferences.kt | 1 + .../datastore/UserPreferencesDataSource.kt | 4 ++++ .../UserPreferencesDataSourceImpl.kt | 13 ++++++++++++ .../repository/UserPreferencesRepository.kt | 10 ++++++++++ .../feature/auth/ui/LoginViewModel.kt | 1 + .../buyornot/feature/home/ui/HomeScreen.kt | 6 +++++- 12 files changed, 87 insertions(+), 2 deletions(-) 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..1324f61e 100644 --- a/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt +++ b/app/src/main/java/com/sseotdabwa/buyornot/ui/BuyOrNotViewModel.kt @@ -2,9 +2,14 @@ package com.sseotdabwa.buyornot.ui 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 +17,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 +28,14 @@ class BuyOrNotViewModel @Inject constructor( initialValue = false, ) + init { + userPreferencesRepository.userId + .distinctUntilChanged() + .onEach { 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..e9068b12 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 @@ -6,4 +6,8 @@ class DebugAnalytics : Analytics { override fun track(event: AnalyticsEvent) { Log.d("Analytics", event.toString()) } + + override fun identify(userId: String?) { + 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..0a693a2b 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 @@ -30,6 +30,11 @@ 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..38e1c67a 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 @@ -371,7 +371,11 @@ private fun HomeFeedList( onDispose { onIntent( HomeIntent.OnFeedScreenExited( - lastVisibleItemIndex = listState.firstVisibleItemIndex, + lastVisibleItemIndex = + listState.layoutInfo.visibleItemsInfo + .lastOrNull() + ?.index + ?: listState.firstVisibleItemIndex, timeSpentSeconds = (System.currentTimeMillis() - enterTimeMs) / 1000f, ), ) From c9f10f46c7fcf557b3af3746c339e66612257060 Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Mon, 11 May 2026 22:36:51 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix/#111:=20DisposableEffect=20=E2=86=92=20?= =?UTF-8?q?LifecycleEventEffect=EB=A1=9C=20FeedExited=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EB=88=84=EB=9D=BD=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveState/restoreState 네비게이션에서 DisposableEffect(Unit)의 onDispose가 호출되지 않는 문제를 ON_STOP 라이프사이클 이벤트로 대체하여 수정 Co-Authored-By: Claude Sonnet 4.6 --- .../buyornot/feature/home/ui/HomeScreen.kt | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) 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 38e1c67a..0751d7ae 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,7 +34,6 @@ 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 @@ -60,6 +59,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 @@ -366,20 +367,20 @@ private fun HomeFeedList( val isMyFeedEmpty = uiState.selectedTab == HomeTab.MY_FEED && isEmptyViewVisible val enterTimeMs = remember { System.currentTimeMillis() } - DisposableEffect(Unit) { + LifecycleEventEffect(Lifecycle.Event.ON_START) { onIntent(HomeIntent.OnFeedScreenEntered(firstVisibleItemIndex = listState.firstVisibleItemIndex)) - onDispose { - onIntent( - HomeIntent.OnFeedScreenExited( - lastVisibleItemIndex = - listState.layoutInfo.visibleItemsInfo - .lastOrNull() - ?.index - ?: 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 From 30e0cad4b646e5fc634828e781b4334142c3fa2b Mon Sep 17 00:00:00 2001 From: dongchyeon Date: Mon, 11 May 2026 22:44:18 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix/#111:=20ON=5FSTART=20=EC=8B=9C=20enterT?= =?UTF-8?q?imeMs=20=EC=B4=88=EA=B8=B0=ED=99=94=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=9E=AC=EB=B0=A9=EB=AC=B8=20=EC=8B=9C=20=EC=B2=B4=EB=A5=98=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveState 네비게이션 환경에서 HomeFeedList가 Composition에 유지되므로 재방문마다 enterTimeMs를 갱신하지 않으면 누적 시간이 전송되는 문제 수정 Co-Authored-By: Claude Sonnet 4.6 --- .../com/sseotdabwa/buyornot/feature/home/ui/HomeScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 0751d7ae..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 @@ -38,6 +38,7 @@ 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 @@ -366,8 +367,9 @@ private fun HomeFeedList( val isEmptyViewVisible = filteredFeeds.isEmpty() && !uiState.isLoading && !uiState.hasError val isMyFeedEmpty = uiState.selectedTab == HomeTab.MY_FEED && isEmptyViewVisible - val enterTimeMs = remember { System.currentTimeMillis() } + var enterTimeMs by remember { mutableLongStateOf(System.currentTimeMillis()) } LifecycleEventEffect(Lifecycle.Event.ON_START) { + enterTimeMs = System.currentTimeMillis() onIntent(HomeIntent.OnFeedScreenEntered(firstVisibleItemIndex = listState.firstVisibleItemIndex)) } LifecycleEventEffect(Lifecycle.Event.ON_STOP) {