From f42593d82aa8b68ab681ad1983cd48674bd12826 Mon Sep 17 00:00:00 2001 From: kimjw Date: Mon, 31 Aug 2026 21:42:34 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8/?= =?UTF-8?q?=EC=98=A8=EB=B3=B4=EB=94=A9=20=EA=B5=AC=EA=B0=84=20Amplitude=20?= =?UTF-8?q?=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B3=84=EC=B8=A1=20(#225=201?= =?UTF-8?q?=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정의서 1단계 8개 이벤트와 사용자 식별을 붙인다. - click_signup: 로그인 화면 시작 버튼. 성공 여부와 무관하게 시도를 남긴다 - complete_login + setUserId: 기존 사용자(isRegistered)만 로그인 성공으로 집계. 신규 가입자는 온보딩을 마쳐야 계정이 생기므로 제외한다 - view_tos / view_filmselect / view_nickname / view_onboarding_done: 각 온보딩 화면 진입 - complete_onboarding + complete_signup + setUserId: 가입 성공 시점 - reset: 로그아웃·탈퇴 성공 시. 초기화하지 않으면 같은 기기에서 다음에 로그인한 사람의 행동이 이전 사용자에 붙는다 온보딩 5개 화면이 그래프 스코프 ViewModel 하나를 공유해 화면별 진입 시점을 ViewModel 로 표현할 수 없다. 진입 이벤트는 UI 계층(TrackScreenView)에서, 완료·성공 이벤트는 ViewModel 에서 보내도록 나눴다. - core/analytics/AnalyticsComposition: LocalAnalyticsTracker 와 TrackScreenView. rememberSaveable 로 전송 여부를 남겨 화면 회전·프로세스 복귀 시 중복 전송을 막는다 - data/analytics/OnboardingDurationStore: 온보딩 시작 시각을 DataStore 에 보관. 여러 화면에 걸쳐 있어 도중에 앱이 종료돼도 최초 시각을 유지해야 한다. 24시간을 넘는 값은 기기 시각 변경이나 며칠 뒤 재개로 보고 소요시간만 생략하며, 완료 건수는 정확해야 하므로 완료 이벤트는 그대로 보낸다 userId 는 가입 응답이 Long, 로그인 응답이 String 이라 문자열로 통일했다. 두 경로가 같은 사용자로 이어지려면 형식이 같아야 한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/analytics/AnalyticsComposition.kt | 40 +++++++++++++ .../android/core/common/util/Constants.kt | 3 + .../data/analytics/OnboardingDurationStore.kt | 60 +++++++++++++++++++ .../android/presentation/MainActivity.kt | 48 ++++++++------- .../android/presentation/login/LoginScreen.kt | 1 + .../presentation/login/LoginViewModel.kt | 13 +++- .../onboarding/OnboardingContentScreen.kt | 4 ++ .../onboarding/OnboardingDoneScreen.kt | 4 ++ .../onboarding/OnboardingProfileScreen.kt | 4 ++ .../onboarding/OnboardingTermsScreen.kt | 4 ++ .../onboarding/OnboardingViewModel.kt | 30 ++++++++++ .../presentation/setting/SettingViewModel.kt | 8 ++- .../setting/withdraw/WithdrawViewModel.kt | 7 ++- 13 files changed, 203 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt create mode 100644 app/src/main/java/com/flint/android/data/analytics/OnboardingDurationStore.kt diff --git a/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt b/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt new file mode 100644 index 00000000..80cdf56a --- /dev/null +++ b/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt @@ -0,0 +1,40 @@ +package com.flint.android.core.analytics + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * 화면 진입 이벤트를 컴포저블에서 바로 보낼 수 있도록 [AnalyticsTracker] 를 트리에 흘려준다. + * + * 온보딩처럼 여러 화면이 ViewModel 하나를 공유하는 구간에서는 화면별 진입 시점을 + * ViewModel 로 표현할 수 없어서, 진입 이벤트만 UI 계층에서 처리한다. + * 완료·성공처럼 비즈니스 로직에 붙는 이벤트는 ViewModel 에서 보낸다. + */ +val LocalAnalyticsTracker = + staticCompositionLocalOf { + error("LocalAnalyticsTracker 가 제공되지 않았습니다. FlintTheme 하위에서 사용하세요.") + } + +/** + * 화면 진입 이벤트를 이 컴포저블이 살아 있는 동안 한 번만 보낸다. + * + * 화면 회전이나 프로세스 종료 후 복귀로 컴포지션이 다시 만들어져도 중복 전송되지 않도록 + * 전송 여부를 [rememberSaveable] 로 남긴다. 뒤로 갔다가 다시 들어오는 경우는 + * 컴포저블이 새로 생성되므로 의도대로 다시 집계된다. + */ +@Composable +fun TrackScreenView(event: FlintEvent) { + val tracker = LocalAnalyticsTracker.current + var isTracked by rememberSaveable(event.eventName) { mutableStateOf(false) } + + LaunchedEffect(event.eventName) { + if (isTracked) return@LaunchedEffect + tracker.track(event) + isTracked = true + } +} diff --git a/app/src/main/java/com/flint/android/core/common/util/Constants.kt b/app/src/main/java/com/flint/android/core/common/util/Constants.kt index 7de7c0f5..1c993c2f 100644 --- a/app/src/main/java/com/flint/android/core/common/util/Constants.kt +++ b/app/src/main/java/com/flint/android/core/common/util/Constants.kt @@ -6,6 +6,9 @@ object DataStoreKey { const val USER_ID = "userId" const val USER_NAME = "userName" const val USER_EMAIL = "userEmail" + + /** 온보딩 소요시간 측정용 시작 시각(밀리초). 온보딩 완료 시 지운다. */ + const val ONBOARDING_STARTED_AT = "onboardingStartedAt" } object ExternalLinks { diff --git a/app/src/main/java/com/flint/android/data/analytics/OnboardingDurationStore.kt b/app/src/main/java/com/flint/android/data/analytics/OnboardingDurationStore.kt new file mode 100644 index 00000000..01ab4e0f --- /dev/null +++ b/app/src/main/java/com/flint/android/data/analytics/OnboardingDurationStore.kt @@ -0,0 +1,60 @@ +package com.flint.android.data.analytics + +import com.flint.android.core.common.util.DataStoreKey +import com.flint.android.data.local.PreferencesManager +import kotlinx.coroutines.flow.first +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** + * 온보딩 시작 시각을 보관해 `complete_onboarding` 의 소요시간을 계산한다. + * + * 온보딩은 약관·작품선택·닉네임까지 여러 화면에 걸쳐 있어 도중에 앱이 종료될 수 있다. + * 메모리에만 두면 그때 시작 시각이 사라지므로 DataStore 에 남긴다. + * + * 시간 기준은 프로세스가 죽어도 이어져야 해서 [System.currentTimeMillis] 를 쓴다. + * 사용자가 기기 시각을 바꾸면 값이 튈 수 있어, 음수이거나 비상식적으로 큰 값은 버린다. + */ +@Singleton +class OnboardingDurationStore @Inject constructor( + private val preferencesManager: PreferencesManager, +) { + /** + * 온보딩 시작 시각을 기록한다. + * + * 이미 기록이 있으면 덮어쓰지 않는다. 온보딩 도중 앱이 종료됐다가 다시 들어온 경우 + * 처음 시작한 시점을 유지해야 실제 소요시간이 나오기 때문이다. + */ + suspend fun startIfAbsent() { + if (readStartedAt() != null) return + preferencesManager.saveString(DataStoreKey.ONBOARDING_STARTED_AT, System.currentTimeMillis().toString()) + } + + /** 시작 시각부터 지금까지의 초. 기록이 없거나 값이 이상하면 null 을 반환한다. */ + suspend fun elapsedSecondsOrNull(): Long? { + val startedAt = readStartedAt() ?: return null + val elapsedMillis = System.currentTimeMillis() - startedAt + + if (elapsedMillis < 0 || elapsedMillis > MAX_REASONABLE_DURATION_MILLIS) { + Timber.w("온보딩 소요시간이 비정상이라 전송하지 않습니다: ${elapsedMillis}ms") + return null + } + return elapsedMillis / MILLIS_PER_SECOND + } + + suspend fun clear() { + preferencesManager.removeString(DataStoreKey.ONBOARDING_STARTED_AT) + } + + private suspend fun readStartedAt(): Long? = + preferencesManager + .getString(DataStoreKey.ONBOARDING_STARTED_AT) + .first() + .toLongOrNull() +} + +private const val MILLIS_PER_SECOND = 1_000L + +/** 하루를 넘긴 값은 기기 시각 변경이나 며칠 뒤 재개로 보고 지표에서 제외한다. */ +private const val MAX_REASONABLE_DURATION_MILLIS = 24 * 60 * 60 * 1_000L diff --git a/app/src/main/java/com/flint/android/presentation/MainActivity.kt b/app/src/main/java/com/flint/android/presentation/MainActivity.kt index efc52b0d..3367a8ea 100644 --- a/app/src/main/java/com/flint/android/presentation/MainActivity.kt +++ b/app/src/main/java/com/flint/android/presentation/MainActivity.kt @@ -6,12 +6,15 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.flint.android.R +import com.flint.android.core.analytics.AnalyticsTracker +import com.flint.android.core.analytics.LocalAnalyticsTracker import com.flint.android.core.designsystem.component.modal.OneButtonModal import com.flint.android.core.designsystem.theme.FlintTheme import com.flint.android.data.di.interceptor.NetworkError @@ -28,35 +31,40 @@ class MainActivity : ComponentActivity() { @Inject lateinit var networkErrorManager: NetworkErrorManager + @Inject + lateinit var analyticsTracker: AnalyticsTracker + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() setContent { FlintTheme { - var networkError by remember { mutableStateOf(null) } + CompositionLocalProvider(LocalAnalyticsTracker provides analyticsTracker) { + var networkError by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { - networkErrorManager.networkError.collect { error -> - networkError = error + LaunchedEffect(Unit) { + networkErrorManager.networkError.collect { error -> + networkError = error + } } - } - val navigator = rememberMainNavigator() - MainScreen( - navigator = navigator, - ) - - networkError?.let { error -> - OneButtonModal( - icon = R.drawable.ic_gradient_none, - title = "문제가 발생했어요", - message = "잠시 후 다시 시도해주세요", - buttonText = "다시 시작하기", - onConfirm = { - restartApplication() - }, - onDismiss = {} + val navigator = rememberMainNavigator() + MainScreen( + navigator = navigator, ) + + networkError?.let { error -> + OneButtonModal( + icon = R.drawable.ic_gradient_none, + title = "문제가 발생했어요", + message = "잠시 후 다시 시도해주세요", + buttonText = "다시 시작하기", + onConfirm = { + restartApplication() + }, + onDismiss = {} + ) + } } } } diff --git a/app/src/main/java/com/flint/android/presentation/login/LoginScreen.kt b/app/src/main/java/com/flint/android/presentation/login/LoginScreen.kt index 78e7e82a..7b586ce1 100644 --- a/app/src/main/java/com/flint/android/presentation/login/LoginScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/login/LoginScreen.kt @@ -63,6 +63,7 @@ fun LoginRoute( LoginScreen( onKakaoLoginClick = { + viewModel.trackSignupClick() kakaoLoginManager.login(context) { result -> result.onSuccess { token -> viewModel.socialVerifyWithKakao( diff --git a/app/src/main/java/com/flint/android/presentation/login/LoginViewModel.kt b/app/src/main/java/com/flint/android/presentation/login/LoginViewModel.kt index 9d97e02e..7aa38a3a 100644 --- a/app/src/main/java/com/flint/android/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/login/LoginViewModel.kt @@ -2,6 +2,8 @@ package com.flint.android.presentation.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flint.android.core.analytics.AnalyticsTracker +import com.flint.android.core.analytics.FlintEvent import com.flint.android.core.common.util.UiState import com.flint.android.domain.model.auth.SocialVerifyRequestModel import com.flint.android.domain.repository.AuthRepository @@ -14,18 +16,27 @@ import javax.inject.Inject @HiltViewModel class LoginViewModel @Inject constructor( - private val authRepository: AuthRepository + private val authRepository: AuthRepository, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { private val _navigationEvent = MutableSharedFlow>() val navigationEvent = _navigationEvent.asSharedFlow() + /** 로그인 화면의 시작 버튼을 눌렀을 때. 성공 여부와 무관하게 시도 자체를 남긴다. */ + fun trackSignupClick() { + analyticsTracker.track(FlintEvent.ClickSignup) + } + fun socialVerifyWithKakao(requestModel: SocialVerifyRequestModel) = viewModelScope.launch { _navigationEvent.emit(UiState.Loading) authRepository.socialVerify(requestModel) .onSuccess { data -> if (data.isRegistered) { + // 신규 가입자는 온보딩을 마쳐야 계정이 생기므로, 기존 사용자만 로그인 성공으로 집계한다. + analyticsTracker.setUserId(data.userId) + analyticsTracker.track(FlintEvent.CompleteLogin) _navigationEvent.emit(UiState.Success(LoginNavigationEvent.NavigateToHome)) } else { _navigationEvent.emit(UiState.Success(LoginNavigationEvent.NavigateToOnBoarding(data.tempToken ?: ""))) diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingContentScreen.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingContentScreen.kt index cf7d3100..6e3fb0b0 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingContentScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingContentScreen.kt @@ -40,6 +40,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flint.android.core.analytics.FlintEvent +import com.flint.android.core.analytics.TrackScreenView import com.flint.android.core.common.util.UiState import com.flint.android.core.designsystem.component.button.FlintButtonState import com.flint.android.core.designsystem.component.button.FlintLargeButton @@ -64,6 +66,8 @@ fun OnboardingContentRoute( ) { val contentUiState by viewModel.contentUiState.collectAsStateWithLifecycle() + TrackScreenView(FlintEvent.ViewFilmSelect) + LaunchedEffect(Unit) { viewModel.loadInitialContents() } diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingDoneScreen.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingDoneScreen.kt index dc93649c..8e2b46bc 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingDoneScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingDoneScreen.kt @@ -26,6 +26,8 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flint.android.R +import com.flint.android.core.analytics.FlintEvent +import com.flint.android.core.analytics.TrackScreenView import com.flint.android.core.designsystem.component.button.FlintButtonState import com.flint.android.core.designsystem.component.button.FlintLargeButton import com.flint.android.core.designsystem.component.topappbar.FlintBackTopAppbar @@ -40,6 +42,8 @@ fun OnboardingDoneRoute( ) { val signupUiState by viewModel.signupUiState.collectAsStateWithLifecycle() + TrackScreenView(FlintEvent.ViewOnboardingDone) + // 회원가입 성공 시 Home으로 이동 LaunchedEffect(signupUiState.isSuccess) { if (signupUiState.isSuccess) { diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingProfileScreen.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingProfileScreen.kt index 8a72ff7e..6a105184 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingProfileScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingProfileScreen.kt @@ -44,6 +44,8 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.chattymin.pebble.graphemeLength import com.flint.android.R +import com.flint.android.core.analytics.FlintEvent +import com.flint.android.core.analytics.TrackScreenView import com.flint.android.core.designsystem.component.bottomsheet.MenuBottomSheet import com.flint.android.core.designsystem.component.bottomsheet.MenuBottomSheetData import com.flint.android.core.designsystem.component.button.FlintButtonState @@ -66,6 +68,8 @@ fun OnboardingProfileRoute( ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() + TrackScreenView(FlintEvent.ViewNickname) + var showToast by remember { mutableStateOf(false) } var toastMessage by remember { mutableStateOf("") } var isToastSuccess by remember { mutableStateOf(false) } diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt index bd946e90..1c353d42 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingTermsScreen.kt @@ -33,6 +33,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flint.android.R +import com.flint.android.core.analytics.FlintEvent +import com.flint.android.core.analytics.TrackScreenView import com.flint.android.core.designsystem.interaction.flintCardClickable import com.flint.android.core.designsystem.interaction.flintIconClickable import com.flint.android.core.common.util.TermGuides @@ -53,6 +55,8 @@ fun OnboardingTermsRoute( ) { val termsUiState by viewModel.termsUiState.collectAsStateWithLifecycle() + TrackScreenView(FlintEvent.ViewTos) + LaunchedEffect(Unit) { viewModel.loadTerms() } diff --git a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt index 5cc9dd27..19ee610b 100644 --- a/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/onboarding/OnboardingViewModel.kt @@ -6,8 +6,11 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.flint.android.core.analytics.AnalyticsTracker +import com.flint.android.core.analytics.FlintEvent import com.flint.android.core.common.util.UiState import com.flint.android.core.navigation.Route +import com.flint.android.data.analytics.OnboardingDurationStore import com.flint.android.domain.model.auth.SignupRequestModel import com.flint.android.domain.model.search.SearchContentItemModel import com.flint.android.domain.repository.AuthRepository @@ -44,11 +47,18 @@ class OnboardingViewModel private val authRepository: AuthRepository, private val storageRepository: StorageRepository, private val termsRepository: TermsRepository, + private val analyticsTracker: AnalyticsTracker, + private val onboardingDurationStore: OnboardingDurationStore, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val tempToken: String = savedStateHandle.toRoute().tempToken + init { + // 이 ViewModel 은 온보딩 그래프에 스코프되어 있어 생성 시점이 곧 온보딩 진입 시점이다. + viewModelScope.launch { onboardingDurationStore.startIfAbsent() } + } + private val _uiState = MutableStateFlow(OnboardingProfileUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -339,6 +349,25 @@ class OnboardingViewModel } } + /** + * 회원가입이 성공한 시점이 곧 온보딩이 끝난 시점이므로 완료 지표를 함께 남긴다. + * + * 소요시간은 시작 기록이 없거나 값이 비정상이면 생략하고, 완료 이벤트만 보낸다. + * 시간이 빠지더라도 완료 건수는 정확해야 하기 때문이다. + */ + private suspend fun trackOnboardingCompleted(userId: Long) { + // 회원가입 응답은 Long, 로그인 응답은 String 으로 타입이 달라 문자열로 맞춘다. + // 두 경로에서 같은 사용자로 이어지려면 형식이 같아야 한다. + analyticsTracker.setUserId(userId.toString()) + + onboardingDurationStore.elapsedSecondsOrNull()?.let { durationSec -> + analyticsTracker.track(FlintEvent.CompleteOnboarding(durationSec)) + } + analyticsTracker.track(FlintEvent.CompleteSignup) + + onboardingDurationStore.clear() + } + // ---------- onboarding signup ---------- fun signup() { // 이미 요청이 진행 중이면 중복 호출하지 않는다. @@ -360,6 +389,7 @@ class OnboardingViewModel authRepository.signup(signupRequest) .onSuccess { response -> _signupUiState.update { it.copy(signupState = UiState.Success(Unit)) } + trackOnboardingCompleted(response.userId) Timber.d("Signup success: userId=${response.userId}") } .onFailure { error -> diff --git a/app/src/main/java/com/flint/android/presentation/setting/SettingViewModel.kt b/app/src/main/java/com/flint/android/presentation/setting/SettingViewModel.kt index 09457cd2..ac22d0c8 100644 --- a/app/src/main/java/com/flint/android/presentation/setting/SettingViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/setting/SettingViewModel.kt @@ -2,6 +2,7 @@ package com.flint.android.presentation.setting import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flint.android.core.analytics.AnalyticsTracker import com.flint.android.core.common.util.DataStoreKey.USER_NAME import com.flint.android.data.local.PreferencesManager import com.flint.android.domain.repository.AuthRepository @@ -23,6 +24,7 @@ class SettingViewModel @Inject constructor( private val preferencesManager: PreferencesManager, private val userRepository: UserRepository, private val authRepository: AuthRepository, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingUiState()) @@ -62,7 +64,11 @@ class SettingViewModel @Inject constructor( fun logout() { viewModelScope.launch { authRepository.logout() - .onSuccess { _navigateToLogin.emit(Unit) } + .onSuccess { + // 초기화하지 않으면 같은 기기에서 다음에 로그인한 사람의 행동이 이전 사용자에 붙는다. + analyticsTracker.reset() + _navigateToLogin.emit(Unit) + } .onFailure { Timber.e(it) } } } diff --git a/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawViewModel.kt b/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawViewModel.kt index b6d3e426..60022dc4 100644 --- a/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/setting/withdraw/WithdrawViewModel.kt @@ -2,6 +2,7 @@ package com.flint.android.presentation.setting.withdraw import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flint.android.core.analytics.AnalyticsTracker import com.flint.android.domain.repository.AuthRepository import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -22,6 +23,7 @@ data class WithdrawUiState( @HiltViewModel class WithdrawViewModel @Inject constructor( private val authRepository: AuthRepository, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { private val _uiState = MutableStateFlow(WithdrawUiState()) @@ -40,7 +42,10 @@ class WithdrawViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(isLoading = true) } authRepository.withdraw() - .onSuccess { _navigateToWithdrawComplete.emit(Unit) } + .onSuccess { + analyticsTracker.reset() + _navigateToWithdrawComplete.emit(Unit) + } .onFailure { Timber.e(it) _uiState.update { state -> state.copy(isLoading = false) } From d73ccead817d5ec72891d868150c0cd2e85ceea2 Mon Sep 17 00:00:00 2001 From: kimjw Date: Mon, 31 Aug 2026 21:49:46 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=ED=99=88/=EB=84=A4=EB=B9=84?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=EC=85=98/=EC=BB=AC=EB=A0=89=EC=85=98=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20Amplitude=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B3=84=EC=B8=A1=20(#225=202=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정의서 2단계 8개 이벤트를 붙인다. - view_home: 로딩이 끝나고 화면이 정상 표시되는 Success 분기에서 보낸다 - click_home_content: 플리너 추천/최근 저장/인기 세 섹션을 각각 구분해 보낸다 - click_bottom_navigation: MainTab 을 정의서의 tab_name 으로 매핑해 보낸다 - view_collection: 상세 화면 진입 시점에 collection_id 와 source 를 함께 보낸다 - save_content / save_collection: 저장 행위만 집계하고 저장 해제는 보내지 않는다 - view_create_collection: 같은 화면을 수정에도 쓰므로 생성 모드에서만 보낸다 - complete_create_collection: 생성 성공만 해당하며 수정 완료는 제외한다 진입 경로(source)를 상세 화면까지 전달하기 위해 Route.CollectionDetail 에 source 를 추가하고 navigateToCollectionDetail 의 필수 인자로 두었다. 기본값을 주면 잘못된 경로가 조용히 섞이므로, 호출부가 반드시 지정하도록 컴파일 단계에서 강제한다. 알 수 없는 값이 들어오면 이벤트를 생략한다. 목록 화면을 거쳐 들어가는 경우에도 원래 지면을 유지하도록 CollectionListRouteType 을 정의서의 5개 값으로 매핑했다. 프로필은 생성한 컬렉션과 저장한 컬렉션이 콜백 하나를 공유하고 있어 my_created 와 my_saved 를 구분할 수 있도록 둘로 나눴다. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/core/analytics/FlintEvent.kt | 11 ++++++++ .../flint/android/core/navigation/Route.kt | 2 ++ .../model/CollectionListRouteType.kt | 15 ++++++++++- .../CollectionCreateScreen.kt | 7 +++++ .../CollectionCreateViewModel.kt | 4 +++ .../navigation/CollectionCreateNavigation.kt | 7 ++++- .../CollectionDetailViewModel.kt | 10 +++++++ .../navigation/CollectionDetailNavigation.kt | 11 ++++++++ .../collectionlist/CollectionListScreen.kt | 8 ++++-- .../navigation/CollectionListNavigation.kt | 3 ++- .../uistate/CollectionListUiState.kt | 3 +++ .../android/presentation/home/HomeScreen.kt | 26 ++++++++++++++++--- .../home/navigation/HomeNavigation.kt | 3 ++- .../android/presentation/main/MainNavHost.kt | 9 ++++++- .../presentation/main/MainNavigator.kt | 3 +++ .../android/presentation/main/MainScreen.kt | 8 +++++- .../android/presentation/main/MainTab.kt | 9 +++++++ .../presentation/profile/ProfileScreen.kt | 20 +++++++++----- .../profile/navigation/ProfileNavigation.kt | 5 ++-- 19 files changed, 144 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt b/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt index 663892d3..d779f5e2 100644 --- a/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt +++ b/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt @@ -174,6 +174,17 @@ enum class CollectionSource( EXPLORE("explore"), MY_SAVED("my_saved"), MY_CREATED("my_created"), + ; + + companion object { + /** + * 네비게이션 인자로 실어 보낸 문자열을 되돌린다. + * + * 타입 안전 라우트가 enum 을 그대로 담지 못해 value 문자열로 오간다. + * 알 수 없는 값이면 잘못된 경로로 집계하는 대신 null 을 반환해 이벤트를 생략한다. + */ + fun from(value: String): CollectionSource? = entries.find { it.value == value } + } } private const val DURATION_SEC = "duration_sec" diff --git a/app/src/main/java/com/flint/android/core/navigation/Route.kt b/app/src/main/java/com/flint/android/core/navigation/Route.kt index f6d4f1b2..a24ce436 100644 --- a/app/src/main/java/com/flint/android/core/navigation/Route.kt +++ b/app/src/main/java/com/flint/android/core/navigation/Route.kt @@ -46,6 +46,8 @@ interface Route { val collectionId: String, val targetImageUrl: String? = null, val showEditSuccessToast: Boolean = false, + /** 분석용 진입 경로. CollectionSource.value 문자열을 담는다. */ + val source: String = "", ) : Route @Serializable diff --git a/app/src/main/java/com/flint/android/core/navigation/model/CollectionListRouteType.kt b/app/src/main/java/com/flint/android/core/navigation/model/CollectionListRouteType.kt index e7046969..cd42329c 100644 --- a/app/src/main/java/com/flint/android/core/navigation/model/CollectionListRouteType.kt +++ b/app/src/main/java/com/flint/android/core/navigation/model/CollectionListRouteType.kt @@ -1,9 +1,22 @@ package com.flint.android.core.navigation.model +import com.flint.android.core.analytics.CollectionSource + enum class CollectionListRouteType( val title: String ) { CREATED(title = "전체 컬렉션"), SAVED(title = "저장 컬렉션"), FAMOUS(title = "인기 컬렉션") -} \ No newline at end of file +} + +/** + * 목록을 거쳐 상세로 들어가도 원래 어느 지면에서 출발했는지를 유지한다. + * 정의서에 정의된 5개 값 안에서 표현한다. + */ +fun CollectionListRouteType.toCollectionSource(): CollectionSource = + when (this) { + CollectionListRouteType.CREATED -> CollectionSource.MY_CREATED + CollectionListRouteType.SAVED -> CollectionSource.MY_SAVED + CollectionListRouteType.FAMOUS -> CollectionSource.HOME_POPULAR + } diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateScreen.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateScreen.kt index 9fd4dfef..395cb211 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateScreen.kt @@ -55,6 +55,8 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import com.flint.android.R +import com.flint.android.core.analytics.FlintEvent +import com.flint.android.core.analytics.TrackScreenView import com.flint.android.core.common.util.UiState import com.flint.android.core.designsystem.component.bottomsheet.MenuBottomSheet import com.flint.android.core.designsystem.component.bottomsheet.MenuBottomSheetData @@ -87,6 +89,11 @@ fun CollectionCreateRoute( ) { val uiState by viewModel.uiState.collectAsState() + // 같은 화면을 수정에도 쓰므로, 정의서의 "컬렉션 생성 페이지 진입" 은 생성 모드에서만 보낸다. + if (!viewModel.isEditMode) { + TrackScreenView(FlintEvent.ViewCreateCollection) + } + LaunchedEffect(Unit) { viewModel.createSuccess.collect { uistate -> when(uistate) { diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt index 6da2a818..2566a2a6 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateViewModel.kt @@ -5,6 +5,8 @@ import android.net.Uri import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.flint.android.core.analytics.AnalyticsTracker +import com.flint.android.core.analytics.FlintEvent import com.flint.android.core.common.util.UiState import com.flint.android.domain.mapper.collection.toDto import com.flint.android.domain.model.collection.CollectionCreateContentModel @@ -43,6 +45,7 @@ class CollectionCreateViewModel @Inject constructor( private val collectionRepository: CollectionRepository, private val searchRepository: SearchRepository, private val storageRepository: StorageRepository, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { private val editingCollectionId: String? = savedStateHandle["collectionId"] @@ -107,6 +110,7 @@ class CollectionCreateViewModel @Inject constructor( .postCollectionCreate(requestModel.toDto()) .onSuccess { println("컬렉션 생성 성공") + analyticsTracker.track(FlintEvent.CompleteCreateCollection(it.collectionId)) _createSuccess.emit(UiState.Success(it.collectionId)) } .onFailure { e -> println("컬렉션 생성 실패: ${e.message}") } diff --git a/app/src/main/java/com/flint/android/presentation/collectioncreate/navigation/CollectionCreateNavigation.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/navigation/CollectionCreateNavigation.kt index f847d1d2..571d06ef 100644 --- a/app/src/main/java/com/flint/android/presentation/collectioncreate/navigation/CollectionCreateNavigation.kt +++ b/app/src/main/java/com/flint/android/presentation/collectioncreate/navigation/CollectionCreateNavigation.kt @@ -8,6 +8,7 @@ import androidx.navigation.NavOptions import androidx.navigation.compose.composable import androidx.navigation.navOptions import androidx.navigation.navigation +import com.flint.android.core.analytics.CollectionSource import com.flint.android.core.common.extension.sharedViewModel import com.flint.android.core.navigation.Route import com.flint.android.presentation.collectioncreate.AddContentRoute @@ -52,6 +53,7 @@ fun NavGraphBuilder.collectionCreateNavGraph( if (viewModel.isEditMode) { navController.navigateToCollectionDetail( collectionId = collectionId, + source = CollectionSource.MY_CREATED, showEditSuccessToast = true, navOptions = navOptions { popUpTo { inclusive = true } @@ -59,7 +61,10 @@ fun NavGraphBuilder.collectionCreateNavGraph( ) } else { navController.popBackStack(inclusive = true) - navController.navigateToCollectionDetail(collectionId = collectionId) + navController.navigateToCollectionDetail( + collectionId = collectionId, + source = CollectionSource.MY_CREATED, + ) } }, viewModel = viewModel diff --git a/app/src/main/java/com/flint/android/presentation/collectiondetail/CollectionDetailViewModel.kt b/app/src/main/java/com/flint/android/presentation/collectiondetail/CollectionDetailViewModel.kt index d22b1761..e1166c11 100644 --- a/app/src/main/java/com/flint/android/presentation/collectiondetail/CollectionDetailViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/collectiondetail/CollectionDetailViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.flint.android.core.analytics.AnalyticsTracker +import com.flint.android.core.analytics.FlintEvent import com.flint.android.core.common.util.DataStoreKey.USER_ID import com.flint.android.core.common.util.UiState import com.flint.android.core.navigation.Route @@ -38,6 +40,7 @@ import javax.inject.Inject class CollectionDetailViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val bookmarkRepository: BookmarkRepository, + private val analyticsTracker: AnalyticsTracker, private val collectionRepository: CollectionRepository, private val preferencesManager: PreferencesManager, ) : ViewModel() { @@ -80,6 +83,10 @@ class CollectionDetailViewModel @Inject constructor( if (currentState != initialCollectionBookmarkState) { bookmarkRepository.toggleCollectionBookmark(uiState.collectionDetail.id) .onSuccess { isBookmarked: Boolean -> + // 정의서의 save_collection 은 저장 행위만 집계한다. 저장 해제는 보내지 않는다. + if (isBookmarked) { + analyticsTracker.track(FlintEvent.SaveCollection(uiState.collectionDetail.id)) + } updateCollectionBookmarkState(isBookmarked) getCollectionBookmarkUsers() _sideEffect.emit( @@ -164,6 +171,9 @@ class CollectionDetailViewModel @Inject constructor( if (currentContent.isBookmarked != initialState) { bookmarkRepository.toggleContentBookmark(contentId) .onSuccess { isBookmarked: Boolean -> + if (isBookmarked) { + analyticsTracker.track(FlintEvent.SaveContent(contentId)) + } updateContentIsBookmarkedOnly( contentId = contentId, isBookmarked = isBookmarked, diff --git a/app/src/main/java/com/flint/android/presentation/collectiondetail/navigation/CollectionDetailNavigation.kt b/app/src/main/java/com/flint/android/presentation/collectiondetail/navigation/CollectionDetailNavigation.kt index 536f7217..9c087ebd 100644 --- a/app/src/main/java/com/flint/android/presentation/collectiondetail/navigation/CollectionDetailNavigation.kt +++ b/app/src/main/java/com/flint/android/presentation/collectiondetail/navigation/CollectionDetailNavigation.kt @@ -6,6 +6,9 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable import androidx.navigation.toRoute +import com.flint.android.core.analytics.CollectionSource +import com.flint.android.core.analytics.FlintEvent +import com.flint.android.core.analytics.TrackScreenView import com.flint.android.core.navigation.Route import com.flint.android.core.navigation.model.CollectionListRouteType import com.flint.android.presentation.collectioncreate.navigation.navigateToCollectionEdit @@ -14,6 +17,7 @@ import com.flint.android.presentation.collectiondetail.report.navigation.navigat fun NavController.navigateToCollectionDetail( collectionId: String, + source: CollectionSource, targetImageUrl: String? = null, showEditSuccessToast: Boolean = false, navOptions: NavOptions? = null, @@ -23,6 +27,7 @@ fun NavController.navigateToCollectionDetail( collectionId = collectionId, targetImageUrl = targetImageUrl, showEditSuccessToast = showEditSuccessToast, + source = source.value, ), navOptions, ) @@ -40,6 +45,12 @@ fun NavGraphBuilder.collectionDetailNavGraph( ) { composable { backStackEntry -> val route = backStackEntry.toRoute() + + // 진입 경로를 알 수 없으면 잘못된 source 로 집계하느니 이벤트를 생략한다. + CollectionSource.from(route.source)?.let { source -> + TrackScreenView(FlintEvent.ViewCollection(route.collectionId, source)) + } + CollectionDetailRoute( paddingValues = paddingValues, targetImageUrl = route.targetImageUrl, diff --git a/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListScreen.kt b/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListScreen.kt index e55aeb2a..bed91828 100644 --- a/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/collectionlist/CollectionListScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flint.android.core.analytics.CollectionSource import com.flint.android.core.designsystem.interaction.flintCardClickable import com.flint.android.core.common.util.UiState import com.flint.android.core.designsystem.component.indicator.FlintLoadingIndicator @@ -36,6 +37,7 @@ import com.flint.android.core.designsystem.component.toast.ShowSaveToast import com.flint.android.core.designsystem.component.toast.ShowToast import com.flint.android.core.designsystem.component.topappbar.FlintBackTopAppbar import com.flint.android.core.designsystem.theme.FlintTheme +import com.flint.android.core.navigation.model.toCollectionSource import com.flint.android.domain.model.collection.CollectionListModel import com.flint.android.core.navigation.model.CollectionListRouteType import com.flint.android.presentation.collectionlist.component.CollectionFileItem @@ -45,7 +47,7 @@ import com.flint.android.presentation.collectionlist.sideeffect.CollectionListSi fun CollectionListRoute( paddingValues: PaddingValues, navigateUp: () -> Unit, - navigateToCollectionDetail: (collectionId: String) -> Unit, + navigateToCollectionDetail: (collectionId: String, source: CollectionSource) -> Unit, navigateToCollectionList: (CollectionListRouteType) -> Unit, viewModel: CollectionListViewModel = hiltViewModel() ) { @@ -57,7 +59,9 @@ fun CollectionListRoute( modifier = Modifier.padding(paddingValues), title = uiState.appbarTitle, onBackClick = navigateUp, - onCollectionItemClick = navigateToCollectionDetail, + onCollectionItemClick = { collectionId -> + navigateToCollectionDetail(collectionId, uiState.routeType.toCollectionSource()) + }, onBookmarkClick = viewModel::toggleCollectionBookmark, collectionList = uiState.collectionList, ) diff --git a/app/src/main/java/com/flint/android/presentation/collectionlist/navigation/CollectionListNavigation.kt b/app/src/main/java/com/flint/android/presentation/collectionlist/navigation/CollectionListNavigation.kt index 51cbe7d7..ddea490d 100644 --- a/app/src/main/java/com/flint/android/presentation/collectionlist/navigation/CollectionListNavigation.kt +++ b/app/src/main/java/com/flint/android/presentation/collectionlist/navigation/CollectionListNavigation.kt @@ -5,6 +5,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import com.flint.android.core.analytics.CollectionSource import com.flint.android.core.navigation.Route import com.flint.android.core.navigation.model.CollectionListRouteType import com.flint.android.presentation.collectionlist.CollectionListRoute @@ -26,7 +27,7 @@ fun NavController.navigateToCollectionList( fun NavGraphBuilder.collectionListNavGraph( paddingValues: PaddingValues, navigateUp: () -> Unit, - navigateToCollectionDetail: (collectionId: String) -> Unit, + navigateToCollectionDetail: (collectionId: String, source: CollectionSource) -> Unit, navigateToCollectionList: (CollectionListRouteType) -> Unit, ) { composable { diff --git a/app/src/main/java/com/flint/android/presentation/collectionlist/uistate/CollectionListUiState.kt b/app/src/main/java/com/flint/android/presentation/collectionlist/uistate/CollectionListUiState.kt index 6e6f1c3a..8ff885b2 100644 --- a/app/src/main/java/com/flint/android/presentation/collectionlist/uistate/CollectionListUiState.kt +++ b/app/src/main/java/com/flint/android/presentation/collectionlist/uistate/CollectionListUiState.kt @@ -1,9 +1,12 @@ package com.flint.android.presentation.collectionlist.uistate import com.flint.android.core.common.util.UiState +import com.flint.android.core.navigation.model.CollectionListRouteType import com.flint.android.domain.model.collection.CollectionListModel data class CollectionListUiState( val appbarTitle: String = "", + /** 상세 진입 시 분석용 source 를 정하기 위해 보관한다. */ + val routeType: CollectionListRouteType = CollectionListRouteType.FAMOUS, val collectionList: UiState = UiState.Loading ) \ No newline at end of file diff --git a/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt b/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt index 82ef89d6..20fb6cc7 100644 --- a/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt @@ -23,6 +23,11 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flint.android.core.analytics.CollectionSource +import com.flint.android.core.analytics.FlintEvent +import com.flint.android.core.analytics.HomeContentType +import com.flint.android.core.analytics.LocalAnalyticsTracker +import com.flint.android.core.analytics.TrackScreenView import com.flint.android.core.common.util.UiState import com.flint.android.core.designsystem.component.bottomsheet.OttListBottomSheet import com.flint.android.core.designsystem.component.listView.CollectionSection @@ -42,12 +47,13 @@ import com.flint.android.presentation.home.sideeffect.HomeSideEffect fun HomeRoute( paddingValues: PaddingValues, navigateToCollectionList: (routeType: CollectionListRouteType) -> Unit, - navigateToCollectionDetail: (collectionId: String) -> Unit, + navigateToCollectionDetail: (collectionId: String, source: CollectionSource) -> Unit, navigateToCollectionCreate: () -> Unit, navigateToExplore: () -> Unit, viewModel: HomeViewModel = hiltViewModel() ) { val uiState by viewModel.homeUiState.collectAsStateWithLifecycle() + val analyticsTracker = LocalAnalyticsTracker.current var showOttListBottomSheet by remember { mutableStateOf(false) } var ottListModel by remember { mutableStateOf(OttListModel()) } @@ -76,16 +82,28 @@ fun HomeRoute( val bookmarkedContentList = (uiState.bookmarkedContentListLoadState as? UiState.Success)?.data ?: BookmarkedContentListModel() val popularCollectionList = (uiState.popularCollectionListLoadState as? UiState.Success)?.data ?: CollectionListModel() + // 정의서상 홈 진입은 "데이터 로딩 완료 후 정상 표시 시점" 이라 Success 분기에서 보낸다. + TrackScreenView(FlintEvent.ViewHome) + HomeScreen( userName = uiState.userName, recommendCollectionModelList = recommendedCollectionList, famousCollectionModelList = popularCollectionList, savedContentModelList = bookmarkedContentList, navigateToCollectionCreate = { navigateToCollectionCreate() }, - onFamousCollectionItemClick = { navigateToCollectionDetail(it) }, + onFamousCollectionItemClick = { + analyticsTracker.track(FlintEvent.ClickHomeContent(HomeContentType.POPULAR)) + navigateToCollectionDetail(it, CollectionSource.HOME_POPULAR) + }, onFamousCollectionAllClick = { navigateToCollectionList(CollectionListRouteType.FAMOUS) }, - onRecommendCollectionItemClick = { navigateToCollectionDetail(it) }, - onSavedContentItemClick = { viewModel.showOttList(it) }, + onRecommendCollectionItemClick = { + analyticsTracker.track(FlintEvent.ClickHomeContent(HomeContentType.FLINER)) + navigateToCollectionDetail(it, CollectionSource.HOME_FLINNER) + }, + onSavedContentItemClick = { + analyticsTracker.track(FlintEvent.ClickHomeContent(HomeContentType.RECENTLY_SAVED)) + viewModel.showOttList(it) + }, modifier = Modifier.padding(paddingValues), ) } diff --git a/app/src/main/java/com/flint/android/presentation/home/navigation/HomeNavigation.kt b/app/src/main/java/com/flint/android/presentation/home/navigation/HomeNavigation.kt index 8dbc0b3d..90a111f1 100644 --- a/app/src/main/java/com/flint/android/presentation/home/navigation/HomeNavigation.kt +++ b/app/src/main/java/com/flint/android/presentation/home/navigation/HomeNavigation.kt @@ -5,6 +5,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import com.flint.android.core.analytics.CollectionSource import com.flint.android.core.navigation.MainTabRoute import com.flint.android.core.navigation.model.CollectionListRouteType import com.flint.android.presentation.home.HomeRoute @@ -16,7 +17,7 @@ fun NavController.navigateToHome(navOptions: NavOptions? = null) { fun NavGraphBuilder.homeNavGraph( paddingValues: PaddingValues, navigateToCollectionList: (routeType: CollectionListRouteType) -> Unit, - navigateToCollectionDetail: (collectionId: String) -> Unit, + navigateToCollectionDetail: (collectionId: String, source: CollectionSource) -> Unit, navigateToCollectionCreate: () -> Unit, navigateToExplore: () -> Unit ) { diff --git a/app/src/main/java/com/flint/android/presentation/main/MainNavHost.kt b/app/src/main/java/com/flint/android/presentation/main/MainNavHost.kt index 711fcc14..64ac1710 100644 --- a/app/src/main/java/com/flint/android/presentation/main/MainNavHost.kt +++ b/app/src/main/java/com/flint/android/presentation/main/MainNavHost.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.navigation.compose.NavHost import androidx.navigation.compose.currentBackStackEntryAsState +import com.flint.android.core.analytics.CollectionSource import com.flint.android.core.designsystem.component.toast.ShowToast import com.flint.android.core.designsystem.theme.FlintTheme import com.flint.android.presentation.collectioncreate.navigation.collectionCreateNavGraph @@ -121,7 +122,13 @@ fun MainNavHost( exploreNavGraph( paddingValues = paddingValues, - navigateToCollectionDetail = navigator::navigateToCollectionDetail, + navigateToCollectionDetail = { collectionId, imageUrl -> + navigator.navigateToCollectionDetail( + collectionId = collectionId, + source = CollectionSource.EXPLORE, + targetImageUrl = imageUrl, + ) + }, navigateToCollectionCreate = navigator::navigateToCollectionCreate, ) diff --git a/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt b/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt index 9b6e6211..bcfd55b6 100644 --- a/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt +++ b/app/src/main/java/com/flint/android/presentation/main/MainNavigator.kt @@ -9,6 +9,7 @@ import androidx.navigation.NavHostController import androidx.navigation.NavOptions import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions +import com.flint.android.core.analytics.CollectionSource import com.flint.android.core.navigation.Route import com.flint.android.core.navigation.model.CollectionListRouteType import com.flint.android.presentation.collectioncreate.navigation.navigateToCollectionCreate @@ -133,11 +134,13 @@ class MainNavigator( fun navigateToCollectionDetail( collectionId: String, + source: CollectionSource, targetImageUrl: String? = null, navOptions: NavOptions? = null, ) { navController.navigateToCollectionDetail( collectionId = collectionId, + source = source, targetImageUrl = targetImageUrl, navOptions = navOptions, ) diff --git a/app/src/main/java/com/flint/android/presentation/main/MainScreen.kt b/app/src/main/java/com/flint/android/presentation/main/MainScreen.kt index aff93e82..8425e5e2 100644 --- a/app/src/main/java/com/flint/android/presentation/main/MainScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/main/MainScreen.kt @@ -10,6 +10,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flint.android.core.analytics.FlintEvent +import com.flint.android.core.analytics.LocalAnalyticsTracker import com.flint.android.core.designsystem.theme.FlintTheme import com.flint.android.presentation.main.component.MainBottomBar import kotlinx.collections.immutable.toImmutableList @@ -19,6 +21,7 @@ fun MainScreen(navigator: MainNavigator) { val isBottomBarVisible by navigator.isBottomBarVisible.collectAsStateWithLifecycle() val currentTab by navigator.currentTab.collectAsStateWithLifecycle() val focusManager = LocalFocusManager.current + val analyticsTracker = LocalAnalyticsTracker.current Box( modifier = Modifier @@ -36,7 +39,10 @@ fun MainScreen(navigator: MainNavigator) { visible = isBottomBarVisible, tabs = MainTab.entries.toImmutableList(), currentTab = currentTab, - onTabSelected = navigator::navigate, + onTabSelected = { tab -> + analyticsTracker.track(FlintEvent.ClickBottomNavigation(tab.toAnalyticsTab())) + navigator.navigate(tab) + }, ) }, ) { paddingValues -> diff --git a/app/src/main/java/com/flint/android/presentation/main/MainTab.kt b/app/src/main/java/com/flint/android/presentation/main/MainTab.kt index cbda96a7..0e690045 100644 --- a/app/src/main/java/com/flint/android/presentation/main/MainTab.kt +++ b/app/src/main/java/com/flint/android/presentation/main/MainTab.kt @@ -2,6 +2,7 @@ package com.flint.android.presentation.main import androidx.annotation.DrawableRes import com.flint.android.R +import com.flint.android.core.analytics.BottomNavigationTab import com.flint.android.core.navigation.MainTabRoute import com.flint.android.core.navigation.Route @@ -35,3 +36,11 @@ enum class MainTab( MainTab.entries.map { it.route }.any { predicate(it) } } } + +/** 정의서의 tab_name 값으로 변환한다. 화면 enum 과 분석 값이 1:1 이 아닐 수 있어 매핑을 분리해 둔다. */ +fun MainTab.toAnalyticsTab(): BottomNavigationTab = + when (this) { + MainTab.HOME -> BottomNavigationTab.HOME + MainTab.EXPLORE -> BottomNavigationTab.EXPLORE + MainTab.PROFILE -> BottomNavigationTab.MY + } diff --git a/app/src/main/java/com/flint/android/presentation/profile/ProfileScreen.kt b/app/src/main/java/com/flint/android/presentation/profile/ProfileScreen.kt index d8f689dd..516d491e 100644 --- a/app/src/main/java/com/flint/android/presentation/profile/ProfileScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/profile/ProfileScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flint.android.core.analytics.CollectionSource import com.flint.android.core.common.extension.noRippleClickable import com.flint.android.core.designsystem.interaction.flintIconClickable import com.flint.android.R @@ -63,7 +64,7 @@ fun ProfileRoute( navigateUp: () -> Unit, navigateToCollectionList: (routeType: CollectionListRouteType, userId: String?) -> Unit, navigateToSavedContentList: (userId: String?) -> Unit, - navigateToCollectionDetail: (collectionId: String) -> Unit, + navigateToCollectionDetail: (collectionId: String, source: CollectionSource) -> Unit, navigateToSetting: () -> Unit = {}, shouldRefreshProfile: Boolean = false, onProfileRefreshed: () -> Unit = {}, @@ -100,7 +101,12 @@ fun ProfileRoute( modifier = Modifier.padding(paddingValues), uiState = uiState, onBackClick = navigateUp, - onCollectionItemClick = navigateToCollectionDetail, + onCreatedCollectionItemClick = { collectionId -> + navigateToCollectionDetail(collectionId, CollectionSource.MY_CREATED) + }, + onSavedCollectionItemClick = { collectionId -> + navigateToCollectionDetail(collectionId, CollectionSource.MY_SAVED) + }, onSettingsClick = navigateToSetting, onContentItemClick = { contentId -> val ottList = (uiState.sectionData as? UiState.Success) @@ -141,7 +147,8 @@ private fun ProfileScreen( onRefreshClick: () -> Unit = {}, onBackClick: () -> Unit = {}, onSettingsClick: () -> Unit = {}, - onCollectionItemClick: (collectionId: String) -> Unit, + onCreatedCollectionItemClick: (collectionId: String) -> Unit, + onSavedCollectionItemClick: (collectionId: String) -> Unit, onContentItemClick: (contentId: String) -> Unit = {}, onContentMoreClick: () -> Unit = {}, onCreatedCollectionMoreClick: () -> Unit, @@ -213,7 +220,7 @@ private fun ProfileScreen( CollectionSection( title = "${userName}님의 컬렉션", description = "${userName}님이 생성한 컬렉션이에요", - onItemClick = onCollectionItemClick, + onItemClick = onCreatedCollectionItemClick, isAllVisible = true, onAllClick = onCreatedCollectionMoreClick, collectionListModel = sectionData.data.createCollections, @@ -228,7 +235,7 @@ private fun ProfileScreen( CollectionSection( title = "저장한 컬렉션", description = "${userName}님이 저장한 컬렉션이에요", - onItemClick = onCollectionItemClick, + onItemClick = onSavedCollectionItemClick, isAllVisible = true, onAllClick = onSavedCollectionMoreClick, collectionListModel = sectionData.data.savedCollections, @@ -295,7 +302,8 @@ private fun ProfileScreenPreview( ProfileScreen( modifier = Modifier.fillMaxSize(), uiState = uiState, - onCollectionItemClick = {}, + onCreatedCollectionItemClick = {}, + onSavedCollectionItemClick = {}, onCreatedCollectionMoreClick = {}, onSavedCollectionMoreClick = {} ) diff --git a/app/src/main/java/com/flint/android/presentation/profile/navigation/ProfileNavigation.kt b/app/src/main/java/com/flint/android/presentation/profile/navigation/ProfileNavigation.kt index 31600dc1..edac8b3c 100644 --- a/app/src/main/java/com/flint/android/presentation/profile/navigation/ProfileNavigation.kt +++ b/app/src/main/java/com/flint/android/presentation/profile/navigation/ProfileNavigation.kt @@ -7,6 +7,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import com.flint.android.core.analytics.CollectionSource import com.flint.android.core.navigation.MainTabRoute import com.flint.android.core.navigation.Route import com.flint.android.core.navigation.model.CollectionListRouteType @@ -31,7 +32,7 @@ fun NavGraphBuilder.myProfileNavGraph( paddingValues: PaddingValues, navigateToCollectionList: (routeType: CollectionListRouteType, userId: String?) -> Unit, navigateToSavedContentList: (userId: String?) -> Unit, - navigateToCollectionDetail: (collectionId: String) -> Unit, + navigateToCollectionDetail: (collectionId: String, source: CollectionSource) -> Unit, navigateToSetting: () -> Unit = {}, ) { composable { entry -> @@ -59,7 +60,7 @@ fun NavGraphBuilder.profileNavGraph( navigateUp: () -> Unit, navigateToCollectionList: (routeType: CollectionListRouteType, userId: String?) -> Unit, navigateToSavedContentList: (userId: String?) -> Unit, - navigateToCollectionDetail: (collectionId: String) -> Unit, + navigateToCollectionDetail: (collectionId: String, source: CollectionSource) -> Unit, ) { composable { ProfileRoute( From 8c8e649caf5ad18a47f51feab6d8f813a6baaddb Mon Sep 17 00:00:00 2001 From: kimjw Date: Mon, 31 Aug 2026 23:04:31 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=ED=83=90=EC=83=89=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=20Amplitude=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B3=84?= =?UTF-8?q?=EC=B8=A1=20(#225=203=EB=8B=A8=EA=B3=84=20=EC=9D=BC=EB=B6=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획 확정 답변(A1~A3)에 따라 탐색 3개 이벤트를 붙인다. - view_explore_content: 머문 시간과 무관하게 페이지가 바뀔 때마다 노출로 집계한다. 마지막 종료 화면은 작품이 아니므로 제외한다 - click_explore_collection: 컬렉션 보러가기 클릭 - exit_explore: 탭 전환·상세 진입·백그라운드 전환을 모두 이탈로 본다 체류시간 측정용 TrackDwellTime 을 core/analytics 에 둔다. 백그라운드 전환을 이탈로 처리하므로 백그라운드 시간은 누적되지 않고, 돌아오면 새 체류로 다시 센다. 기기 시각 변경에 영향받지 않도록 elapsedRealtime 을 쓰고, 한 체류에 이벤트가 두 번 나가지 않게 막았다. 로딩·빈 화면·에러도 탐색 페이지에 머문 시간이라 화면 상태와 무관하게 Route 전체를 감싼다. 주의: 이탈 기준이 넓어 탐색 한 번이 여러 건으로 쪼개진다. 실제 체류시간을 보려면 세션 단위 합산이 필요하다. update_keyword, view_saved_content, view_saved_collection 은 정의서 해석 확인(B-1~B-3) 후 진행한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/analytics/AnalyticsComposition.kt | 58 +++++++++++++++++++ .../presentation/explore/ExploreScreen.kt | 28 ++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt b/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt index 80cdf56a..284c26f5 100644 --- a/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt +++ b/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt @@ -1,12 +1,18 @@ package com.flint.android.core.analytics +import android.os.SystemClock import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner /** * 화면 진입 이벤트를 컴포저블에서 바로 보낼 수 있도록 [AnalyticsTracker] 를 트리에 흘려준다. @@ -38,3 +44,55 @@ fun TrackScreenView(event: FlintEvent) { isTracked = true } } + +/** + * 화면에 머문 시간을 재고, 벗어날 때 [onExit] 으로 초 단위 값을 넘긴다. + * + * 기획 정의상 탭 전환·상세 진입·백그라운드 전환을 모두 이탈로 본다. + * 백그라운드로 나간 순간 이탈로 처리하므로 백그라운드에 머문 시간은 애초에 누적되지 않고, + * 돌아오면 새 체류로 다시 센다. + * + * 시간은 기기 시각 변경에 영향받지 않도록 [SystemClock.elapsedRealtime] 을 쓴다. + * 한 번의 체류에 대해 이벤트가 두 번 나가지 않도록 전송 여부를 함께 관리한다. + * + * 앱이 강제 종료되면 이탈을 알릴 기회가 없다. SDK 가 로컬 큐에 쌓아 다음 실행에 보내주지만, + * 앱을 다시 열지 않는 사용자의 기록은 남지 않는다. + */ +@Composable +fun TrackDwellTime(onExit: (durationSec: Long) -> Unit) { + val currentOnExit by rememberUpdatedState(onExit) + val lifecycleOwner = LocalLifecycleOwner.current + + DisposableEffect(lifecycleOwner) { + var startedAt = SystemClock.elapsedRealtime() + var isSent = false + + fun sendOnce() { + if (isSent) return + isSent = true + currentOnExit((SystemClock.elapsedRealtime() - startedAt) / MILLIS_PER_SECOND) + } + + val observer = LifecycleEventObserver { _, event -> + when (event) { + Lifecycle.Event.ON_STOP -> sendOnce() + Lifecycle.Event.ON_START -> { + // 돌아왔으면 새로운 체류의 시작이다. + startedAt = SystemClock.elapsedRealtime() + isSent = false + } + else -> Unit + } + } + + lifecycleOwner.lifecycle.addObserver(observer) + + onDispose { + lifecycleOwner.lifecycle.removeObserver(observer) + // 탭 전환·상세 진입·뒤로가기로 화면을 벗어난 경우. + sendOnce() + } + } +} + +private const val MILLIS_PER_SECOND = 1_000L diff --git a/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt b/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt index bea98b73..4c8a62d7 100644 --- a/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -32,6 +33,9 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flint.android.R +import com.flint.android.core.analytics.FlintEvent +import com.flint.android.core.analytics.LocalAnalyticsTracker +import com.flint.android.core.analytics.TrackDwellTime import com.flint.android.core.common.util.UiState import com.flint.android.core.designsystem.component.button.FlintButtonState import com.flint.android.core.designsystem.component.button.FlintLargeButton @@ -43,6 +47,7 @@ import com.flint.android.domain.model.exploration.ExplorationItemModel import com.flint.android.presentation.explore.uistate.ExploreUiState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.flow.distinctUntilChanged @Composable fun ExploreRoute( @@ -52,6 +57,12 @@ fun ExploreRoute( viewModel: ExploreViewModel = hiltViewModel(), ) { val uiState: UiState by viewModel.uiState.collectAsStateWithLifecycle() + val analyticsTracker = LocalAnalyticsTracker.current + + // 로딩·빈 화면·에러도 탐색 페이지에 머문 시간이므로 상태와 무관하게 전체를 감싼다. + TrackDwellTime { durationSec -> + analyticsTracker.track(FlintEvent.ExitExplore(durationSec)) + } when (uiState) { UiState.Loading -> { @@ -68,7 +79,10 @@ fun ExploreRoute( items = data.items, isEnd = data.isEnd, initialPage = data.initialPage, - onWatchCollectionButtonClick = navigateToCollectionDetail, + onWatchCollectionButtonClick = { collectionId, imageUrl -> + analyticsTracker.track(FlintEvent.ClickExploreCollection(collectionId)) + navigateToCollectionDetail(collectionId, imageUrl) + }, onMakeCollectionButtonClick = navigateToCollectionCreate, onLoadNextSession = viewModel::advanceToNextSession, modifier = Modifier.padding(paddingValues), @@ -109,6 +123,18 @@ private fun ExploreScreen( } } + // 기획 확정 기준: 머문 시간과 무관하게 페이지가 바뀔 때마다 노출로 집계한다. + // 마지막 페이지는 작품이 아닌 종료 화면이라 items 범위 밖은 보내지 않는다. + val exploreAnalyticsTracker = LocalAnalyticsTracker.current + LaunchedEffect(pagerState, items) { + snapshotFlow { pagerState.currentPage } + .distinctUntilChanged() + .collect { page -> + val item = items.getOrNull(page) ?: return@collect + exploreAnalyticsTracker.track(FlintEvent.ViewExploreContent(item.contentId)) + } + } + Column( modifier .run { From 755c3d24962b73dd03e111c78180752d5e3129e5 Mon Sep 17 00:00:00 2001 From: kimjw Date: Mon, 31 Aug 2026 23:20:17 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=ED=82=A4=EC=9B=8C=EB=93=9C=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EA=B3=84=EC=B8=A1=20(#225=203=EB=8B=A8=EA=B3=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update_keyword 를 마이 화면의 키워드 새로고침에 붙인다. 정의서에는 발생 지면이 my/keyword 로 적혀 있으나 앱에 해당 화면이 없고, 기획 확인 결과 현재 마이 화면 안의 키워드 재계산 버튼을 뜻하는 것으로 확정됐다. 재계산 요청이 성공한 시점만 집계한다. 버튼을 눌렀어도 실패하면 키워드가 갱신되지 않으므로 업데이트로 볼 수 없다. Co-Authored-By: Claude Opus 5 (1M context) --- .../flint/android/presentation/profile/ProfileViewModel.kt | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/src/main/java/com/flint/android/presentation/profile/ProfileViewModel.kt b/app/src/main/java/com/flint/android/presentation/profile/ProfileViewModel.kt index 9c0c21e0..226e316a 100644 --- a/app/src/main/java/com/flint/android/presentation/profile/ProfileViewModel.kt +++ b/app/src/main/java/com/flint/android/presentation/profile/ProfileViewModel.kt @@ -4,6 +4,8 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.navigation.toRoute +import com.flint.android.core.analytics.AnalyticsTracker +import com.flint.android.core.analytics.FlintEvent import com.flint.android.core.common.util.UiState import com.flint.android.core.common.util.suspendRunCatching import com.flint.android.core.navigation.Route @@ -34,6 +36,7 @@ class ProfileViewModel @Inject constructor( private val userRepository: UserRepository, private val contentRepository: ContentRepository, private val bookmarkRepository: BookmarkRepository, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { val userId = savedStateHandle.toRoute().userId @@ -162,6 +165,9 @@ class ProfileViewModel @Inject constructor( _uiState.update { it.copy(isRecalculating = true) } userRepository.recalculateKeywords() .onSuccess { + // 정의서의 update_keyword. 재계산이 성공한 시점만 집계한다. + analyticsTracker.track(FlintEvent.UpdateKeyword) + // 버튼 즉시 비활성화 후 키워드 재조회 _uiState.update { it.copy(profile = it.profile.copy(keywordRecalculatable = false)) } userRepository.getUserKeywords(userId = null) From a54e64f04af96ad2008e0f4eb8106807f817c2c1 Mon Sep 17 00:00:00 2001 From: kimjw Date: Mon, 31 Aug 2026 23:25:23 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=ED=83=90=EC=83=89=20=EC=A7=84?= =?UTF-8?q?=EC=9E=85=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?(#225)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 정의서에 없던 view_explore 를 기획 확인 후 추가한다. 진입 수와 이탈 수를 대조해야 exit_explore 의 기록 유실을 확인할 수 있다. 발생 기준은 view_home 과 동일하게, 로딩이 끝나고 콘텐츠가 실제로 표시된 시점으로 잡았다. 다만 exit_explore 는 로딩·빈 화면·에러 상태에서도 발생하므로 두 이벤트의 집계 범위가 다르다. 콘텐츠가 없는 방문은 이탈만 남아 진입보다 이탈이 많게 집계되니, 대조에 쓰려면 범위를 맞춰야 한다. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/com/flint/android/core/analytics/FlintEvent.kt | 9 +++++++++ .../flint/android/presentation/explore/ExploreScreen.kt | 4 ++++ 2 files changed, 13 insertions(+) diff --git a/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt b/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt index d779f5e2..22dae05d 100644 --- a/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt +++ b/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt @@ -103,6 +103,15 @@ sealed class FlintEvent( // ────────── 탐색 ────────── + /** + * 탐색 페이지 진입. + * + * 정의서에는 없던 이벤트로, 진입 수와 이탈 수를 대조해 기록 유실을 확인하기 위해 + * 기획 확인 후 추가했다. view_home 과 같은 기준으로 데이터 로딩이 끝나고 + * 콘텐츠가 실제로 표시된 시점에 보낸다. + */ + data object ViewExplore : FlintEvent("view_explore") + /** * 탐색에서 개별 작품 노출. * diff --git a/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt b/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt index 4c8a62d7..f1638381 100644 --- a/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt @@ -36,6 +36,7 @@ import com.flint.android.R import com.flint.android.core.analytics.FlintEvent import com.flint.android.core.analytics.LocalAnalyticsTracker import com.flint.android.core.analytics.TrackDwellTime +import com.flint.android.core.analytics.TrackScreenView import com.flint.android.core.common.util.UiState import com.flint.android.core.designsystem.component.button.FlintButtonState import com.flint.android.core.designsystem.component.button.FlintLargeButton @@ -75,6 +76,9 @@ fun ExploreRoute( if (data.isEmpty) { ExploreEmptyPage(modifier = Modifier.padding(paddingValues)) } else { + // 기획 확정 기준: 로딩이 끝나고 콘텐츠가 실제로 표시된 시점. + TrackScreenView(FlintEvent.ViewExplore) + ExploreScreen( items = data.items, isEnd = data.isEnd, From 590c60fff68cfa637456e63485cd40b41337b5be Mon Sep 17 00:00:00 2001 From: kimjw Date: Mon, 31 Aug 2026 23:28:18 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=ED=83=90=EC=83=89=20=EC=9D=B4?= =?UTF-8?q?=ED=83=88=20=EC=A7=91=EA=B3=84=20=EB=B2=94=EC=9C=84=EB=A5=BC=20?= =?UTF-8?q?=EC=BD=98=ED=85=90=EC=B8=A0=20=ED=91=9C=EC=8B=9C=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=EC=9C=BC=EB=A1=9C=20=EB=A7=9E=EC=B6=A4=20(#225)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획 확정에 따라 exit_explore 도 view_explore 와 같은 구간에서만 기록한다. 이전에는 로딩·빈 화면·에러 상태에서도 이탈이 기록되어, 콘텐츠가 없는 방문은 이탈만 남고 진입은 남지 않았다. 그 상태로는 두 수가 항상 어긋나 기록 유실을 확인할 수 없었다. 세션 전환은 Success 를 유지하므로 도중에 이탈이 잘못 기록되지 않는다. Co-Authored-By: Claude Opus 5 (1M context) --- .../android/presentation/explore/ExploreScreen.kt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt b/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt index f1638381..da8ec160 100644 --- a/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/explore/ExploreScreen.kt @@ -60,11 +60,6 @@ fun ExploreRoute( val uiState: UiState by viewModel.uiState.collectAsStateWithLifecycle() val analyticsTracker = LocalAnalyticsTracker.current - // 로딩·빈 화면·에러도 탐색 페이지에 머문 시간이므로 상태와 무관하게 전체를 감싼다. - TrackDwellTime { durationSec -> - analyticsTracker.track(FlintEvent.ExitExplore(durationSec)) - } - when (uiState) { UiState.Loading -> { FlintLoadingIndicator() @@ -79,6 +74,12 @@ fun ExploreRoute( // 기획 확정 기준: 로딩이 끝나고 콘텐츠가 실제로 표시된 시점. TrackScreenView(FlintEvent.ViewExplore) + // 진입과 이탈의 집계 범위를 맞춰야 두 수를 대조해 유실을 확인할 수 있다. + // 그래서 체류시간도 콘텐츠가 표시된 구간에서만 잰다. + TrackDwellTime { durationSec -> + analyticsTracker.track(FlintEvent.ExitExplore(durationSec)) + } + ExploreScreen( items = data.items, isEnd = data.isEnd, From 0ac72dfa0fa320ebfed85092ed7a23abd6df5246 Mon Sep 17 00:00:00 2001 From: kimjw Date: Tue, 1 Sep 2026 11:25:11 +0900 Subject: [PATCH 7/8] =?UTF-8?q?feat:=20=EC=A0=80=EC=9E=A5=ED=95=9C=20?= =?UTF-8?q?=EC=9E=91=ED=92=88=20=EB=85=B8=EC=B6=9C=20=EA=B3=84=EC=B8=A1=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A0=95=EC=9D=98=EC=84=9C=20=ED=99=95=EC=A0=95?= =?UTF-8?q?=EC=82=AC=ED=95=AD=20=EB=B0=98=EC=98=81=20(#225=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기획 최종 답변을 반영해 남은 이벤트를 마무리한다. - view_saved_collection 제외 저장함에서 어떤 컬렉션을 다시 열어봤는지는 view_collection 의 source(my_saved) 로 확인할 수 있어 중복이므로 이벤트 자체를 두지 않는다. - view_saved_content 노출 기준 적용 작품 카드가 화면에 50% 이상 드러난 채로 1초 이상 유지되면 집계하고, 같은 저장함 방문 안에서는 작품당 한 번만 보낸다. 스크롤로 빠르게 지나친 항목과 오르내리며 다시 지나친 항목이 제외된다. - 플리너 표기를 fliner 로 통일 (home_flinner 는 오타) 노출 판정용 Modifier.onItemImpression 을 core/analytics 에 둔다. 가시 비율은 잘려서 실제로 보이는 높이를 항목 전체 높이로 나눠 구하고, 기준 시간을 채우기 전에 화면을 벗어나면 코루틴이 취소되어 집계되지 않는다. 이로써 정의서 21개 이벤트(원안 22개 중 1개 제외, view_explore 추가) 계측을 마쳤다. Co-Authored-By: Claude Opus 5 (1M context) --- .../core/analytics/AnalyticsComposition.kt | 42 +++++++++++++++++++ .../android/core/analytics/FlintEvent.kt | 16 ++++--- .../android/presentation/home/HomeScreen.kt | 2 +- .../profile/SavedContentScreen.kt | 20 ++++++++- 4 files changed, 69 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt b/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt index 284c26f5..b82034fc 100644 --- a/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt +++ b/app/src/main/java/com/flint/android/core/analytics/AnalyticsComposition.kt @@ -6,13 +6,18 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.compose.LocalLifecycleOwner +import kotlinx.coroutines.delay /** * 화면 진입 이벤트를 컴포저블에서 바로 보낼 수 있도록 [AnalyticsTracker] 를 트리에 흘려준다. @@ -96,3 +101,40 @@ fun TrackDwellTime(onExit: (durationSec: Long) -> Unit) { } private const val MILLIS_PER_SECOND = 1_000L + +/** + * 목록 항목이 화면에 충분히 오래 보였을 때 노출로 집계한다. + * + * 기준은 기획 확정값이다 — 항목 높이의 [minVisibleFraction] 이상이 화면에 드러난 채로 + * [minVisibleMillis] 이상 유지되어야 한다. 스크롤로 빠르게 지나친 항목은 제외된다. + * + * 한 번 보낸 항목은 [alreadyTracked] 로 걸러 같은 방문 안에서 다시 보내지 않는다. + * 위아래로 오르내리며 같은 항목을 여러 번 지나쳐도 한 번만 집계된다. + * + * 가시 비율은 잘려서 실제로 보이는 높이를 항목 전체 높이로 나눠 구한다. + */ +@Composable +fun Modifier.onItemImpression( + key: Any, + alreadyTracked: (Any) -> Boolean, + onImpression: (Any) -> Unit, + minVisibleFraction: Float = 0.5f, + minVisibleMillis: Long = 1_000L, +): Modifier { + val currentOnImpression by rememberUpdatedState(onImpression) + val currentAlreadyTracked by rememberUpdatedState(alreadyTracked) + var isSufficientlyVisible by remember(key) { mutableStateOf(false) } + + LaunchedEffect(key, isSufficientlyVisible) { + if (!isSufficientlyVisible || currentAlreadyTracked(key)) return@LaunchedEffect + // 이 시간이 지나기 전에 화면에서 벗어나면 코루틴이 취소되어 집계되지 않는다. + delay(minVisibleMillis) + if (!currentAlreadyTracked(key)) currentOnImpression(key) + } + + return onGloballyPositioned { coordinates -> + val height = coordinates.size.height + val visibleHeight = coordinates.boundsInWindow().height + isSufficientlyVisible = height > 0 && visibleHeight / height >= minVisibleFraction + } +} diff --git a/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt b/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt index 22dae05d..a272d564 100644 --- a/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt +++ b/app/src/main/java/com/flint/android/core/analytics/FlintEvent.kt @@ -143,23 +143,21 @@ sealed class FlintEvent( /** 사용자 키워드 업데이트 완료 */ data object UpdateKeyword : FlintEvent("update_keyword") - /** 저장한 작품 목록에서 이전에 저장한 작품을 다시 확인 */ + /** + * 저장한 작품 목록에서 이전에 저장한 작품을 다시 확인. + * + * 저장함에서 어떤 컬렉션을 다시 열어봤는지는 view_collection 의 source(my_saved)로 + * 알 수 있어, 컬렉션 쪽 대응 이벤트는 중복이므로 두지 않는다. + */ data class ViewSavedContent( val contentId: String, ) : FlintEvent("view_saved_content", mapOf(CONTENT_ID to contentId)) - - /** 저장한 컬렉션 목록에서 이전에 저장한 컬렉션을 다시 확인 */ - data class ViewSavedCollection( - val collectionId: String, - ) : FlintEvent("view_saved_collection", mapOf(COLLECTION_ID to collectionId)) } /** 홈에서 클릭한 콘텐츠 영역 */ enum class HomeContentType( val value: String, ) { - // 정의서의 content_type 은 "fliner", source 는 "home_flinner" 로 철자가 다르다. - // 어느 쪽이 맞는지 기획 확인 후 한쪽으로 통일할 것. FLINER("fliner"), RECENTLY_SAVED("recently_saved"), POPULAR("popular"), @@ -178,7 +176,7 @@ enum class BottomNavigationTab( enum class CollectionSource( val value: String, ) { - HOME_FLINNER("home_flinner"), + HOME_FLINER("home_fliner"), HOME_POPULAR("home_popular"), EXPLORE("explore"), MY_SAVED("my_saved"), diff --git a/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt b/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt index 20fb6cc7..f6edcf28 100644 --- a/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/home/HomeScreen.kt @@ -98,7 +98,7 @@ fun HomeRoute( onFamousCollectionAllClick = { navigateToCollectionList(CollectionListRouteType.FAMOUS) }, onRecommendCollectionItemClick = { analyticsTracker.track(FlintEvent.ClickHomeContent(HomeContentType.FLINER)) - navigateToCollectionDetail(it, CollectionSource.HOME_FLINNER) + navigateToCollectionDetail(it, CollectionSource.HOME_FLINER) }, onSavedContentItemClick = { analyticsTracker.track(FlintEvent.ClickHomeContent(HomeContentType.RECENTLY_SAVED)) diff --git a/app/src/main/java/com/flint/android/presentation/profile/SavedContentScreen.kt b/app/src/main/java/com/flint/android/presentation/profile/SavedContentScreen.kt index d4916f7c..afd7a744 100644 --- a/app/src/main/java/com/flint/android/presentation/profile/SavedContentScreen.kt +++ b/app/src/main/java/com/flint/android/presentation/profile/SavedContentScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -29,6 +30,9 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flint.android.R +import com.flint.android.core.analytics.FlintEvent +import com.flint.android.core.analytics.LocalAnalyticsTracker +import com.flint.android.core.analytics.onItemImpression import com.flint.android.core.common.util.UiState import com.flint.android.core.designsystem.component.bottomsheet.OttListBottomSheet import com.flint.android.core.designsystem.component.indicator.FlintLoadingIndicator @@ -232,6 +236,11 @@ private fun SavedContentList( onMoreClick: (ottList: List) -> Unit, modifier: Modifier = Modifier, ) { + val analyticsTracker = LocalAnalyticsTracker.current + + // 같은 저장함 방문 안에서는 작품당 한 번만 보낸다. + // 화면을 벗어나면 함께 사라지므로 다시 들어오면 새 방문으로 집계된다. + val trackedContentIds = rememberSaveable { arrayListOf() } if (contents.isEmpty()) { Box( modifier = modifier.fillMaxSize(), @@ -252,7 +261,16 @@ private fun SavedContentList( key = { it.id }, ) { content -> CollectionCreateContentBookmark( - modifier = Modifier.animateItem(), + modifier = Modifier + .animateItem() + .onItemImpression( + key = content.id, + alreadyTracked = { id -> id in trackedContentIds }, + onImpression = { id -> + trackedContentIds.add(id as String) + analyticsTracker.track(FlintEvent.ViewSavedContent(id)) + }, + ), onBookmarkClick = { onBookmarkClick(content.id) }, onMoreClick = { onMoreClick(content.getOttSimpleList) }, isBookmarked = content.isBookmarked, From ac40a6fd82594dde5fcfc1328149ac00228d246f Mon Sep 17 00:00:00 2001 From: kimjw Date: Wed, 2 Sep 2026 21:44:15 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20Amplitude=20=EC=98=A4=ED=94=84?= =?UTF-8?q?=EB=9D=BC=EC=9D=B8=20=ED=81=90=EC=9E=89=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20ACCESS=5FNETWORK=5FSTATE=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 에뮬레이터 검증 중 SDK 가 아래 경고를 남기는 것을 확인했다. No ACCESS_NETWORK_STATE permission, offline mode is not supported. 권한이 없으면 SDK 가 네트워크 상태를 감지하지 못해 연결이 끊긴 동안 발생한 이벤트가 유실될 수 있다. 특히 exit_explore 는 화면을 벗어나는 순간에만 기록되어 재전송 기회가 적으므로 영향이 크다. 사용자 동의를 묻지 않는 일반 권한이다. 추가 후 경고가 사라지는 것을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) --- app/src/main/AndroidManifest.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 50100b9e..40ba10bb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,9 @@ + +