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 @@ + + { + 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 + } +} + +/** + * 화면에 머문 시간을 재고, 벗어날 때 [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 + +/** + * 목록 항목이 화면에 충분히 오래 보였을 때 노출로 집계한다. + * + * 기준은 기획 확정값이다 — 항목 높이의 [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 663892d3..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 @@ -103,6 +103,15 @@ sealed class FlintEvent( // ────────── 탐색 ────────── + /** + * 탐색 페이지 진입. + * + * 정의서에는 없던 이벤트로, 진입 수와 이탈 수를 대조해 기록 유실을 확인하기 위해 + * 기획 확인 후 추가했다. view_home 과 같은 기준으로 데이터 로딩이 끝나고 + * 콘텐츠가 실제로 표시된 시점에 보낸다. + */ + data object ViewExplore : FlintEvent("view_explore") + /** * 탐색에서 개별 작품 노출. * @@ -134,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"), @@ -169,11 +176,22 @@ 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"), 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/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/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/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/collectioncreate/CollectionCreateScreen.kt b/app/src/main/java/com/flint/android/presentation/collectioncreate/CollectionCreateScreen.kt index 3127894b..521cad82 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 @@ -56,6 +56,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 @@ -91,6 +93,11 @@ fun CollectionCreateRoute( val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current + // 같은 화면을 수정에도 쓰므로, 정의서의 "컬렉션 생성 페이지 진입" 은 생성 모드에서만 보낸다. + 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 7cdc0968..020f2367 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 @@ -44,6 +46,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"] @@ -112,6 +115,7 @@ class CollectionCreateViewModel @Inject constructor( collectionRepository .postCollectionCreate(requestModel.toDto()) .onSuccess { + analyticsTracker.track(FlintEvent.CompleteCreateCollection(it.collectionId)) _createSuccess.emit(UiState.Success(it.collectionId)) } .onFailure { e -> 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 506003e6..654ca482 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 @@ -30,6 +30,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 @@ -37,6 +38,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 @@ -46,7 +48,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() ) { @@ -60,7 +62,9 @@ fun CollectionListRoute( routeType = uiState.routeType, isMyProfile = uiState.userId == null, 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 2021c284..440724f7 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 @@ -6,6 +6,7 @@ import com.flint.android.domain.model.collection.CollectionListModel data class CollectionListUiState( val appbarTitle: String = "", + /** 목록 표시 방식과, 상세 진입 시 분석용 source 를 정하는 데 함께 쓰인다. */ val routeType: CollectionListRouteType = CollectionListRouteType.CREATED, val userId: String? = null, val collectionList: UiState = UiState.Loading 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..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 @@ -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,10 @@ 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.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 @@ -43,6 +48,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 +58,7 @@ fun ExploreRoute( viewModel: ExploreViewModel = hiltViewModel(), ) { val uiState: UiState by viewModel.uiState.collectAsStateWithLifecycle() + val analyticsTracker = LocalAnalyticsTracker.current when (uiState) { UiState.Loading -> { @@ -64,11 +71,23 @@ fun ExploreRoute( if (data.isEmpty) { ExploreEmptyPage(modifier = Modifier.padding(paddingValues)) } else { + // 기획 확정 기준: 로딩이 끝나고 콘텐츠가 실제로 표시된 시점. + TrackScreenView(FlintEvent.ViewExplore) + + // 진입과 이탈의 집계 범위를 맞춰야 두 수를 대조해 유실을 확인할 수 있다. + // 그래서 체류시간도 콘텐츠가 표시된 구간에서만 잰다. + TrackDwellTime { durationSec -> + analyticsTracker.track(FlintEvent.ExitExplore(durationSec)) + } + ExploreScreen( 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 +128,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 { 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..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 @@ -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_FLINER) + }, + 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/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/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/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 9f39fbe7..5a786410 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 @@ -47,6 +47,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 @@ -70,6 +72,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 69a7fd04..22ac157d 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 @@ -5,8 +5,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 @@ -37,11 +40,18 @@ class OnboardingViewModel private val authRepository: AuthRepository, private val profileImageUploader: ProfileImageUploader, 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() @@ -332,6 +342,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() { // 이미 요청이 진행 중이면 중복 호출하지 않는다. @@ -355,6 +384,7 @@ class OnboardingViewModel .onSuccess { response -> uploadProfileImageIfNeeded() _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/profile/ProfileScreen.kt b/app/src/main/java/com/flint/android/presentation/profile/ProfileScreen.kt index d6354025..0af7bc88 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/ProfileViewModel.kt b/app/src/main/java/com/flint/android/presentation/profile/ProfileViewModel.kt index 95bc9c0b..11e619e7 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 @@ -36,6 +38,7 @@ class ProfileViewModel @Inject constructor( private val contentRepository: ContentRepository, private val bookmarkRepository: BookmarkRepository, private val collectionRepository: CollectionRepository, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { val userId = savedStateHandle.toRoute().userId @@ -236,6 +239,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) 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 2cf552b5..d8988829 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 @@ -238,6 +242,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(), @@ -258,7 +267,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, 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( 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) }