diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2c98eed29..0f2b3cc4a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(projects.core.model) implementation(projects.core.navigation) implementation(projects.core.ui) + implementation(projects.core.analytics) implementation(projects.feature.auth.api) implementation(projects.feature.auth.impl) implementation(projects.feature.pose.api) @@ -89,7 +90,6 @@ dependencies { implementation(libs.timber) implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.analytics) implementation(libs.firebase.crashlytics) implementation(libs.androidx.activity.compose) diff --git a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureImplConventionPlugin.kt b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureImplConventionPlugin.kt index 95ffd3b20..15170079b 100644 --- a/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureImplConventionPlugin.kt +++ b/build-logic/src/main/java/com/neki/android/buildlogic/plugins/AndroidFeatureImplConventionPlugin.kt @@ -18,6 +18,7 @@ class AndroidFeatureImplConventionPlugin : Plugin { "implementation"(project(":core:common")) "implementation"(project(":core:domain")) "implementation"(project(":core:ui")) + "implementation"(project(":core:analytics")) "implementation"(libs.findLibrary("androidx.hilt.lifecycle.viewModel.compose").get()) } diff --git a/core/analytics/build.gradle.kts b/core/analytics/build.gradle.kts new file mode 100644 index 000000000..8b5c74c92 --- /dev/null +++ b/core/analytics/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + alias(libs.plugins.neki.android.library) + alias(libs.plugins.neki.hilt) +} + +android { + namespace = "com.neki.android.core.analytics" +} + +dependencies { + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.analytics) +} diff --git a/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/AnalyticsEvent.kt b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/AnalyticsEvent.kt new file mode 100644 index 000000000..da05a658d --- /dev/null +++ b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/AnalyticsEvent.kt @@ -0,0 +1,8 @@ +package com.neki.android.core.analytics.event + +sealed interface AnalyticsEvent { + + val name: String + val params: Map + get() = emptyMap() +} diff --git a/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/ArchiveAnalyticsEvent.kt b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/ArchiveAnalyticsEvent.kt new file mode 100644 index 000000000..814d3cffe --- /dev/null +++ b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/ArchiveAnalyticsEvent.kt @@ -0,0 +1,49 @@ +package com.neki.android.core.analytics.event + +sealed interface ArchiveAnalyticsEvent : AnalyticsEvent { + + data object ArchivingView : ArchiveAnalyticsEvent { + override val name = "archiving_view" + } + + data class PhotoUpload(val method: String, val count: Int) : ArchiveAnalyticsEvent { + override val name = "photo_upload" + override val params = mapOf( + "method" to method, + "count" to count, + ) + } + + data object AlbumCreate : ArchiveAnalyticsEvent { + override val name = "album_create" + } + + data class AlbumAddFromDetail(val albumCount: Int) : ArchiveAnalyticsEvent { + override val name = "album_add_from_detail" + override val params = mapOf("album_count" to albumCount) + } + + data class AlbumAddFromMulti(val photoCount: Int, val albumCount: Int) : ArchiveAnalyticsEvent { + override val name = "album_add_from_multi" + override val params = mapOf( + "photo_count" to photoCount, + "album_count" to albumCount, + ) + } + + data object PhotoMove : ArchiveAnalyticsEvent { + override val name = "photo_move" + } + + data object PhotoCopy : ArchiveAnalyticsEvent { + override val name = "photo_copy" + } + + data object PhotoDetailView : ArchiveAnalyticsEvent { + override val name = "photo_detail_view" + } + + data object PhotoMemoCreate : ArchiveAnalyticsEvent { + override val name = "photo_memo_create" + } +} diff --git a/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/GlobalAnalyticsEvent.kt b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/GlobalAnalyticsEvent.kt new file mode 100644 index 000000000..7c7942d19 --- /dev/null +++ b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/GlobalAnalyticsEvent.kt @@ -0,0 +1,8 @@ +package com.neki.android.core.analytics.event + +sealed interface GlobalAnalyticsEvent : AnalyticsEvent { + + data object AppOpen : GlobalAnalyticsEvent { + override val name = "app_open" + } +} diff --git a/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/MapAnalyticsEvent.kt b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/MapAnalyticsEvent.kt new file mode 100644 index 000000000..e719fa404 --- /dev/null +++ b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/MapAnalyticsEvent.kt @@ -0,0 +1,42 @@ +package com.neki.android.core.analytics.event + +sealed interface MapAnalyticsEvent : AnalyticsEvent { + + data object MapView : MapAnalyticsEvent { + override val name = "map_view" + } + + data class MapReSearch(val hasFilter: Boolean, val regionChanged: Boolean) : MapAnalyticsEvent { + override val name = "map_re_search" + override val params = mapOf( + "has_filter" to hasFilter, + "region_changed" to regionChanged, + ) + } + + data class MapBrandFilterToggle( + val action: String, + val selectedCount: Int, + val brandName: String, + ) : MapAnalyticsEvent { + override val name = "map_brand_filter_toggle" + override val params = mapOf( + "action" to action, + "selected_count" to selectedCount, + "brand_name" to brandName, + ) + } + + data class BoothSelect(val entryPoint: String, val brandName: String) : MapAnalyticsEvent { + override val name = "booth_select" + override val params = mapOf( + "entry_point" to entryPoint, + "brand_name" to brandName, + ) + } + + data class MapRouteClick(val mapType: String) : MapAnalyticsEvent { + override val name = "map_route_click" + override val params = mapOf("map_type" to mapType) + } +} diff --git a/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/MypageAnalyticsEvent.kt b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/MypageAnalyticsEvent.kt new file mode 100644 index 000000000..1007921cb --- /dev/null +++ b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/MypageAnalyticsEvent.kt @@ -0,0 +1,12 @@ +package com.neki.android.core.analytics.event + +sealed interface MypageAnalyticsEvent : AnalyticsEvent { + + data object Logout : MypageAnalyticsEvent { + override val name = "mypage_logout" + } + + data object Withdraw : MypageAnalyticsEvent { + override val name = "mypage_withdraw" + } +} diff --git a/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/PoseAnalyticsEvent.kt b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/PoseAnalyticsEvent.kt new file mode 100644 index 000000000..30f97e794 --- /dev/null +++ b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/event/PoseAnalyticsEvent.kt @@ -0,0 +1,30 @@ +package com.neki.android.core.analytics.event + +sealed interface PoseAnalyticsEvent : AnalyticsEvent { + + data object PoseView : PoseAnalyticsEvent { + override val name = "pose_view" + } + + data object PoseRandomStart : PoseAnalyticsEvent { + override val name = "pose_random_start" + } + + data class PoseRandomSessionEnd(val totalSwipeCount: Int) : PoseAnalyticsEvent { + override val name = "pose_random_session_end" + override val params = mapOf("total_swipe_count" to totalSwipeCount) + } + + data class PoseFilterToggle(val peopleCount: Int) : PoseAnalyticsEvent { + override val name = "pose_filter_toggle" + override val params = mapOf("people_count" to peopleCount) + } + + data object PoseBookmarkFilter : PoseAnalyticsEvent { + override val name = "pose_bookmark_filter" + } + + data object PoseBookmark : PoseAnalyticsEvent { + override val name = "pose_bookmark" + } +} diff --git a/core/analytics/src/main/kotlin/com/neki/android/core/analytics/logger/AnalyticsLogger.kt b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/logger/AnalyticsLogger.kt new file mode 100644 index 000000000..d8337474e --- /dev/null +++ b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/logger/AnalyticsLogger.kt @@ -0,0 +1,9 @@ +package com.neki.android.core.analytics.logger + +import com.neki.android.core.analytics.event.AnalyticsEvent + +interface AnalyticsLogger { + fun log(event: AnalyticsEvent) + fun setUserId(userId: String) + fun setUserProperty(key: String, value: String) +} diff --git a/core/analytics/src/main/kotlin/com/neki/android/core/analytics/logger/AnalyticsModule.kt b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/logger/AnalyticsModule.kt new file mode 100644 index 000000000..617f4a427 --- /dev/null +++ b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/logger/AnalyticsModule.kt @@ -0,0 +1,28 @@ +package com.neki.android.core.analytics.logger + +import android.content.Context +import com.google.firebase.analytics.FirebaseAnalytics +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class AnalyticsModule { + + @Binds + @Singleton + abstract fun bindAnalyticsLogger(impl: FirebaseAnalyticsLogger): AnalyticsLogger + + companion object { + @Provides + @Singleton + fun provideFirebaseAnalytics( + @ApplicationContext context: Context, + ): FirebaseAnalytics = FirebaseAnalytics.getInstance(context) + } +} diff --git a/core/analytics/src/main/kotlin/com/neki/android/core/analytics/logger/FirebaseAnalyticsLogger.kt b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/logger/FirebaseAnalyticsLogger.kt new file mode 100644 index 000000000..485cc652a --- /dev/null +++ b/core/analytics/src/main/kotlin/com/neki/android/core/analytics/logger/FirebaseAnalyticsLogger.kt @@ -0,0 +1,35 @@ +package com.neki.android.core.analytics.logger + +import android.os.Bundle +import com.google.firebase.analytics.FirebaseAnalytics +import com.neki.android.core.analytics.event.AnalyticsEvent +import javax.inject.Inject + +internal class FirebaseAnalyticsLogger @Inject constructor( + private val firebaseAnalytics: FirebaseAnalytics, +) : AnalyticsLogger { + + override fun log(event: AnalyticsEvent) { + val bundle = Bundle().apply { + event.params.forEach { (key, value) -> + when (value) { + is String -> putString(key, value) + is Int -> putInt(key, value) + is Long -> putLong(key, value) + is Double -> putDouble(key, value) + is Boolean -> putBoolean(key, value) + else -> putString(key, value.toString()) + } + } + } + firebaseAnalytics.logEvent(event.name, bundle) + } + + override fun setUserId(userId: String) { + firebaseAnalytics.setUserId(userId) + } + + override fun setUserProperty(key: String, value: String) { + firebaseAnalytics.setUserProperty(key, value) + } +} diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt index 92f9a9321..4a53321ff 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album/AllAlbumViewModel.kt @@ -2,6 +2,8 @@ package com.neki.android.feature.archive.impl.album import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.analytics.event.ArchiveAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.dataapi.repository.FolderRepository import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.model.AlbumPreview @@ -22,6 +24,7 @@ import javax.inject.Inject class AllAlbumViewModel @Inject constructor( private val photoRepository: PhotoRepository, private val folderRepository: FolderRepository, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { val store: MviIntentStore = @@ -182,6 +185,7 @@ class AllAlbumViewModel @Inject constructor( viewModelScope.launch { folderRepository.createFolder(name = albumName) .onSuccess { + analyticsLogger.log(ArchiveAnalyticsEvent.AlbumCreate) fetchFolders(reduce) postSideEffect(AllAlbumSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) postSideEffect(AllAlbumSideEffect.NotifyResult) diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt index aea25b69a..0f966f9ff 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/album_detail/AlbumDetailViewModel.kt @@ -8,6 +8,8 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map +import com.neki.android.core.analytics.event.ArchiveAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.dataapi.repository.FolderRepository import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.domain.usecase.UploadMultiplePhotoUseCase @@ -41,6 +43,7 @@ class AlbumDetailViewModel @AssistedInject constructor( private val photoRepository: PhotoRepository, private val folderRepository: FolderRepository, private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { @AssistedFactory @@ -245,7 +248,7 @@ class AlbumDetailViewModel @AssistedInject constructor( } postSideEffect( AlbumDetailSideEffect.NavigateToSelectAlbum( - SelectAlbumAction.CopyPhotos(photoIds = photoIds), + SelectAlbumAction.CopyPhotos(photoIds = photoIds, fromPhotoDetail = false), ), ) } @@ -310,6 +313,7 @@ class AlbumDetailViewModel @AssistedInject constructor( photoIds = state.importPhotoState.selectedPhotoIds.toList(), targetFolderIds = listOf(albumId), ).onSuccess { + analyticsLogger.log(ArchiveAnalyticsEvent.PhotoCopy) reduce { copy(isShowImportPhotoBottomSheet = false, importPhotoState = ImportPhotoState()) } _importAlbumFilter.value = null postSideEffect(AlbumDetailSideEffect.ShowToastMessage("사진을 앨범에 추가했어요")) diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt index d1966fdbd..7c37bb356 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridCells import androidx.compose.foundation.lazy.staggeredgrid.StaggeredGridItemSpan import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -60,6 +61,10 @@ internal fun ArchiveMainRoute( val lazyState = rememberLazyStaggeredGridState() val nekiToast = remember { NekiToast(context) } + LaunchedEffect(Unit) { + viewModel.logArchivingView() + } + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { ArchiveMainSideEffect.NavigateToQRScan -> navigateToQRScan() diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt index d490e6e7e..3400c3d73 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/main/ArchiveMainViewModel.kt @@ -3,6 +3,8 @@ package com.neki.android.feature.archive.impl.main import androidx.compose.foundation.text.input.TextFieldState import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.analytics.event.ArchiveAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.dataapi.repository.FolderRepository import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.dataapi.repository.UserRepository @@ -26,6 +28,7 @@ class ArchiveMainViewModel @Inject constructor( private val photoRepository: PhotoRepository, private val folderRepository: FolderRepository, private val userRepository: UserRepository, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { val store: MviIntentStore = @@ -39,6 +42,10 @@ class ArchiveMainViewModel @Inject constructor( store.onIntent(ArchiveMainIntent.EnterArchiveMainScreen) } + fun logArchivingView() { + analyticsLogger.log(ArchiveAnalyticsEvent.ArchivingView) + } + private fun onIntent( intent: ArchiveMainIntent, state: ArchiveMainState, @@ -173,6 +180,7 @@ class ArchiveMainViewModel @Inject constructor( viewModelScope.launch { folderRepository.createFolder(name = albumName) .onSuccess { + analyticsLogger.log(ArchiveAnalyticsEvent.AlbumCreate) fetchFolders(reduce) postSideEffect(ArchiveMainSideEffect.ShowToastMessage("새로운 앨범을 추가했어요")) } diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt index 3797c5548..f3a40c64d 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/navigation/ArchiveEntryProvider.kt @@ -109,7 +109,7 @@ private fun EntryProviderScope.archiveEntry(navigator: MainNavigator) { navigateToPhotoDetail = navigator::navigateToPhotoDetail, navigateToSelectAlbum = { photoIds -> navigator.navigateToSelectAlbum( - action = SelectAlbumAction.CopyPhotos(photoIds = photoIds), + action = SelectAlbumAction.CopyPhotos(photoIds = photoIds, fromPhotoDetail = false), title = "앨범에 추가", multiSelect = true, ) @@ -189,7 +189,7 @@ private fun EntryProviderScope.archiveEntry(navigator: MainNavigator) { navigateBack = navigator::goBack, navigateToSelectAlbum = { photoId -> navigator.navigateToSelectAlbum( - action = SelectAlbumAction.CopyPhotos(listOf(photoId), false), + action = SelectAlbumAction.CopyPhotos(photoIds = listOf(photoId), fromPhotoDetail = true), title = "모든 앨범", multiSelect = false, ) diff --git a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt index 0570167fa..4a65e0261 100644 --- a/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt +++ b/feature/archive/impl/src/main/kotlin/com/neki/android/feature/archive/impl/photo_detail/PhotoDetailViewModel.kt @@ -2,6 +2,8 @@ package com.neki.android.feature.archive.impl.photo_detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.analytics.event.ArchiveAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.common.coroutine.di.ApplicationScope import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.ui.MviIntentStore @@ -25,6 +27,7 @@ class PhotoDetailViewModel @AssistedInject constructor( @Assisted private val key: ArchiveNavKey.PhotoDetail, private val photoRepository: PhotoRepository, @ApplicationScope private val applicationScope: CoroutineScope, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { private val favoriteRequests = MutableSharedFlow>(extraBufferCapacity = 64) @@ -43,6 +46,8 @@ class PhotoDetailViewModel @AssistedInject constructor( ) init { + analyticsLogger.log(ArchiveAnalyticsEvent.PhotoDetailView) + viewModelScope.launch { favoriteRequests .debounce(500) @@ -200,6 +205,7 @@ class PhotoDetailViewModel @AssistedInject constructor( viewModelScope.launch { photoRepository.updateMemo(photoId, newMemo) .onSuccess { + analyticsLogger.log(ArchiveAnalyticsEvent.PhotoMemoCreate) postSideEffect(PhotoDetailSideEffect.NotifyPhotoUpdated) } .onFailure { e -> diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginViewModel.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginViewModel.kt index 25f84ccde..1da33a608 100644 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginViewModel.kt +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/login/LoginViewModel.kt @@ -2,6 +2,7 @@ package com.neki.android.feature.auth.impl.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.dataapi.repository.AuthRepository import com.neki.android.core.dataapi.repository.TokenRepository import com.neki.android.core.dataapi.repository.UserRepository @@ -17,6 +18,7 @@ class LoginViewModel @Inject constructor( private val authRepository: AuthRepository, private val tokenRepository: TokenRepository, private val userRepository: UserRepository, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { val store: MviIntentStore = mviIntentStore( @@ -63,6 +65,8 @@ class LoginViewModel @Inject constructor( ) { userRepository.getUserInfo() .onSuccess { userInfo -> + analyticsLogger.setUserId(userInfo.id.toString()) + analyticsLogger.setUserProperty("platform", "android") if (userInfo.isRequiredTermsAgreed) { authRepository.setCompletedOnboarding(true) postSideEffect(LoginSideEffect.NavigateToMain) diff --git a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashViewModel.kt b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashViewModel.kt index cfabccc1a..902d7a9ac 100644 --- a/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashViewModel.kt +++ b/feature/auth/impl/src/main/kotlin/com/neki/android/feature/auth/impl/splash/SplashViewModel.kt @@ -2,6 +2,8 @@ package com.neki.android.feature.auth.impl.splash import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.analytics.event.GlobalAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.dataapi.repository.AuthRepository import com.neki.android.core.dataapi.repository.TokenRepository import com.neki.android.core.ui.MviIntentStore @@ -17,6 +19,7 @@ import javax.inject.Inject class SplashViewModel @Inject constructor( private val tokenRepository: TokenRepository, private val authRepository: AuthRepository, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { val store: MviIntentStore = @@ -43,6 +46,7 @@ class SplashViewModel @Inject constructor( reduce: (SplashState.() -> SplashState) -> Unit, postSideEffect: (SplashSideEffect) -> Unit, ) { + analyticsLogger.setUserProperty("app_version", currentAppVersion) viewModelScope.launch { authRepository.getAppVersion() .onSuccess { appVersion -> @@ -100,6 +104,7 @@ class SplashViewModel @Inject constructor( authRepository.updateAccessToken( refreshToken = tokenRepository.getRefreshToken().first(), ).onSuccess { + analyticsLogger.log(GlobalAnalyticsEvent.AppOpen) tokenRepository.saveTokens(it.accessToken, it.refreshToken) postSideEffect(SplashSideEffect.NavigateToMain) }.onFailure { e -> diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt index 9f9592548..33417e567 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapContract.kt @@ -34,7 +34,7 @@ sealed interface MapIntent { data class LoadPhotoBoothsByBounds(val mapBounds: MapBounds) : MapIntent data class ClickPhotoBoothMarker(val locLatLng: LocLatLng) : MapIntent data class ClickClusterMarker(val southWest: LocLatLng, val northEast: LocLatLng) : MapIntent - data class ClickRefreshButton(val mapBounds: MapBounds) : MapIntent + data class ClickRefreshButton(val mapBounds: MapBounds, val center: LocLatLng, val zoomLevel: Double) : MapIntent data object ClickDirectionIcon : MapIntent data object GestureOnMap : MapIntent diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt index 55ee05abd..1ed2af6b3 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapScreen.kt @@ -68,6 +68,10 @@ fun MapRoute( val scope = rememberCoroutineScope() val nekiToast = remember { NekiToast(context) } + LaunchedEffect(Unit) { + viewModel.logMapView() + } + var locationTrackingMode by remember { mutableStateOf(LocationTrackingMode.None) } val cameraPositionState = rememberCameraPositionState { position = CameraPosition( @@ -222,6 +226,7 @@ fun MapScreen( MapProperties( locationTrackingMode = locationTrackingMode, minZoom = MapConst.MIN_ZOOM_LEVEL, + maxZoom = MapConst.MAX_ZOOM_LEVEL, ) } val mapUiSettings = remember { @@ -311,12 +316,17 @@ fun MapScreen( cameraPositionState.contentBounds?.let { bounds -> onIntent( MapIntent.ClickRefreshButton( - MapBounds( + mapBounds = MapBounds( southWest = LocLatLng(bounds.southWest.latitude, bounds.southWest.longitude), northWest = LocLatLng(bounds.northWest.latitude, bounds.northWest.longitude), northEast = LocLatLng(bounds.northEast.latitude, bounds.northEast.longitude), southEast = LocLatLng(bounds.southEast.latitude, bounds.southEast.longitude), ), + center = LocLatLng( + cameraPositionState.position.target.latitude, + cameraPositionState.position.target.longitude, + ), + zoomLevel = cameraPositionState.position.zoom, ), ) } diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt index 51f309831..00878de5e 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/MapViewModel.kt @@ -9,6 +9,8 @@ import coil3.request.ImageRequest import coil3.request.SuccessResult import coil3.request.allowHardware import coil3.toBitmap +import com.neki.android.core.analytics.event.MapAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.common.permission.LocationPermissionManager import com.neki.android.core.dataapi.repository.MapRepository import com.neki.android.core.dataapi.repository.UserRepository @@ -37,13 +39,20 @@ class MapViewModel @Inject constructor( @ApplicationContext private val context: Context, private val mapRepository: MapRepository, private val userRepository: UserRepository, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { + + private var lastSearchCenter: LocLatLng? = null val store: MviIntentStore = mviIntentStore( initialState = MapState(), onIntent = ::onIntent, initialFetchData = { store.onIntent(MapIntent.EnterMapScreen) }, ) + fun logMapView() { + analyticsLogger.log(MapAnalyticsEvent.MapView) + } + private fun onIntent( intent: MapIntent, state: MapState, @@ -53,7 +62,9 @@ class MapViewModel @Inject constructor( when (intent) { MapIntent.EnterMapScreen -> fetchInitialData(reduce) is MapIntent.GrantedLocationPermission -> getCurrentLocation(reduce, postSideEffect) - is MapIntent.LoadPhotoBoothsByBounds -> loadPhotoBoothsByPolygon(intent.mapBounds, state, reduce, postSideEffect) + is MapIntent.LoadPhotoBoothsByBounds -> { + loadPhotoBoothsByPolygon(intent.mapBounds, state, reduce, postSideEffect) + } MapIntent.ClickCurrentLocationIcon -> { if (LocationPermissionManager.isGrantedLocationPermission(context)) { moveCurrentLocation(state, reduce, postSideEffect) @@ -64,6 +75,13 @@ class MapViewModel @Inject constructor( MapIntent.GestureOnMap -> reduce { copy(isCameraOnCurrentLocation = false, isVisibleRefreshButton = true) } is MapIntent.ClickRefreshButton -> { + analyticsLogger.log( + MapAnalyticsEvent.MapReSearch( + hasFilter = state.brands.any { it.isChecked }, + regionChanged = isRegionChanged(intent.center, intent.zoomLevel), + ), + ) + lastSearchCenter = intent.center reduce { copy(isVisibleRefreshButton = false) } loadPhotoBoothsByPolygon(intent.mapBounds, state, reduce, postSideEffect) } @@ -71,7 +89,7 @@ class MapViewModel @Inject constructor( MapIntent.ClickInfoIcon -> reduce { copy(isShowInfoTooltip = true) } MapIntent.DismissInfoTooltip -> reduce { copy(isShowInfoTooltip = false) } MapIntent.ClickToMapChip -> reduce { copy(dragLevel = DragLevel.FIRST) } - is MapIntent.ClickVerticalBrand -> handleClickBrand(intent.brand, reduce) + is MapIntent.ClickVerticalBrand -> handleClickBrand(intent.brand, state, reduce) is MapIntent.ClickNearPhotoBooth -> handleClickNearPhotoBooth(intent.photoBooth, reduce, postSideEffect) MapIntent.ClickClosePhotoBoothCard -> reduce { copy( @@ -84,7 +102,7 @@ class MapViewModel @Inject constructor( MapIntent.CloseDirectionBottomSheet -> reduce { copy(isShowDirectionBottomSheet = false) } is MapIntent.ClickDirectionItem -> handleClickDirectionItem(state, intent.app, reduce, postSideEffect) is MapIntent.ChangeDragLevel -> handleChangeDragLevel(intent.dragLevel, state.shouldShowInfoTooltip, reduce) - is MapIntent.ClickPhotoBoothMarker -> handleClickPhotoBoothMarker(intent.locLatLng, reduce, postSideEffect) + is MapIntent.ClickPhotoBoothMarker -> handleClickPhotoBoothMarker(intent.locLatLng, state, reduce, postSideEffect) is MapIntent.ClickClusterMarker -> postSideEffect(MapEffect.ZoomToClusterBounds(intent.southWest, intent.northEast)) is MapIntent.ClickPhotoBoothCard -> handleClickPhotoBoothCard(intent.locLatLng, postSideEffect) MapIntent.ClickDirectionIcon -> { @@ -173,18 +191,25 @@ class MapViewModel @Inject constructor( private fun handleClickBrand( clickedBrand: Brand, + state: MapState, reduce: (MapState.() -> MapState) -> Unit, ) { - reduce { - val updatedBrands = brands.map { brand -> - if (brand == clickedBrand) { - brand.copy(isChecked = !brand.isChecked) - } else { - brand - } + val updatedBrands = state.brands.map { brand -> + if (brand == clickedBrand) { + brand.copy(isChecked = !brand.isChecked) + } else { + brand } + } + analyticsLogger.log( + MapAnalyticsEvent.MapBrandFilterToggle( + action = if (clickedBrand.isChecked) "deselect" else "select", + selectedCount = updatedBrands.count { it.isChecked }, + brandName = clickedBrand.name, + ), + ) + reduce { val checkedBrandNames = updatedBrands.filter { it.isChecked }.map { it.name } - copy( brands = updatedBrands.toImmutableList(), mapMarkers = mapMarkers.map { photoBooth -> @@ -206,6 +231,12 @@ class MapViewModel @Inject constructor( reduce: (MapState.() -> MapState) -> Unit, postSideEffect: (MapEffect) -> Unit, ) { + analyticsLogger.log( + MapAnalyticsEvent.BoothSelect( + entryPoint = "bottom_sheet", + brandName = photoBooth.brandName, + ), + ) reduce { val isAlreadyInMarkers = mapMarkers.any { it.latitude == photoBooth.latitude && it.longitude == photoBooth.longitude @@ -231,6 +262,15 @@ class MapViewModel @Inject constructor( reduce: (MapState.() -> MapState) -> Unit, postSideEffect: (MapEffect) -> Unit, ) { + analyticsLogger.log( + MapAnalyticsEvent.MapRouteClick( + mapType = when (app) { + DirectionApp.KAKAO_MAP -> "kakao_map" + DirectionApp.NAVER_MAP -> "naver_map" + DirectionApp.GOOGLE_MAP -> "google_map" + }, + ), + ) reduce { copy(isShowDirectionBottomSheet = false) } if (state.currentLocLatLng == null) { postSideEffect(MapEffect.ShowToastMessage("현재 위치를 가져올 수 없습니다.")) @@ -249,9 +289,20 @@ class MapViewModel @Inject constructor( private fun handleClickPhotoBoothMarker( locLatLng: LocLatLng, + state: MapState, reduce: (MapState.() -> MapState) -> Unit, postSideEffect: (MapEffect) -> Unit, ) { + state.mapMarkers.find { + it.latitude == locLatLng.latitude && it.longitude == locLatLng.longitude + }?.let { booth -> + analyticsLogger.log( + MapAnalyticsEvent.BoothSelect( + entryPoint = "map", + brandName = booth.brandName, + ), + ) + } reduce { val updatedMarkers = mapMarkers.map { marker -> val isClicked = marker.latitude == locLatLng.latitude && marker.longitude == locLatLng.longitude @@ -356,6 +407,18 @@ class MapViewModel @Inject constructor( } } + private fun isRegionChanged(currentCenter: LocLatLng, zoomLevel: Double): Boolean { + val prev = lastSearchCenter ?: return false + val distance = calculateDistance(prev.latitude, prev.longitude, currentCenter.latitude, currentCenter.longitude) + val threshold = when { + zoomLevel >= 18 -> 300 + zoomLevel >= 16 -> 500 + zoomLevel >= 14 -> 700 + else -> 1000 + } + return distance >= threshold + } + private fun loadPhotoBoothsByPolygon( mapBounds: MapBounds, state: MapState, diff --git a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt index 418ec80eb..135a6b6e2 100644 --- a/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt +++ b/feature/map/impl/src/main/java/com/neki/android/feature/map/impl/const/MapConst.kt @@ -6,8 +6,9 @@ internal object MapConst { internal const val DEFAULT_LONGITUDE = 127.027610 // 기본 줌 레벨 - internal const val DEFAULT_ZOOM_LEVEL = 17.0 + internal const val DEFAULT_ZOOM_LEVEL = 14.0 internal const val MIN_ZOOM_LEVEL = 12.0 + internal const val MAX_ZOOM_LEVEL = 20.0 internal const val DEFAULT_CAMERA_ANIMATION_DURATIONS_MS = 800 diff --git a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt index 918d946fe..ec0e49c48 100644 --- a/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt +++ b/feature/mypage/impl/src/main/java/com/neki/android/feature/mypage/impl/main/MyPageViewModel.kt @@ -2,6 +2,8 @@ package com.neki.android.feature.mypage.impl.main import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.analytics.event.MypageAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.dataapi.repository.AuthRepository import com.neki.android.core.dataapi.repository.TokenRepository import com.neki.android.core.dataapi.repository.UserRepository @@ -23,6 +25,7 @@ internal class MyPageViewModel @Inject constructor( private val userRepository: UserRepository, private val authRepository: AuthRepository, private val tokenRepository: TokenRepository, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { val store: MviIntentStore = @@ -181,6 +184,7 @@ internal class MyPageViewModel @Inject constructor( } private fun logout(postSideEffect: (MyPageEffect) -> Unit) = viewModelScope.launch { + analyticsLogger.log(MypageAnalyticsEvent.Logout) tokenRepository.clearTokensWithAuthCache() postSideEffect(MyPageEffect.LogoutWithKakao) } @@ -192,6 +196,7 @@ internal class MyPageViewModel @Inject constructor( reduce { copy(isLoading = true) } authRepository.withdrawAccount() .onSuccess { + analyticsLogger.log(MypageAnalyticsEvent.Withdraw) tokenRepository.clearTokensWithAuthCache() authRepository.setCompletedOnboarding(false) reduce { copy(isLoading = false) } diff --git a/feature/photo-upload/impl/build.gradle.kts b/feature/photo-upload/impl/build.gradle.kts index e5c798633..a58b7a020 100644 --- a/feature/photo-upload/impl/build.gradle.kts +++ b/feature/photo-upload/impl/build.gradle.kts @@ -58,5 +58,6 @@ dependencies { implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.compose) + implementation(libs.guava) } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt index 37340b53b..1edb11dc9 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/detail/PoseDetailViewModel.kt @@ -2,6 +2,8 @@ package com.neki.android.feature.pose.impl.detail import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.analytics.event.PoseAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.common.coroutine.di.ApplicationScope import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.ui.MviIntentStore @@ -23,6 +25,7 @@ class PoseDetailViewModel @AssistedInject constructor( @Assisted private val id: Long, private val poseRepository: PoseRepository, @ApplicationScope private val applicationScope: CoroutineScope, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { private val bookmarkRequests = MutableSharedFlow(extraBufferCapacity = 64) @@ -85,6 +88,7 @@ class PoseDetailViewModel @AssistedInject constructor( reduce: (PoseDetailState.() -> PoseDetailState) -> Unit, postSideEffect: (PoseDetailSideEffect) -> Unit, ) { + analyticsLogger.log(PoseAnalyticsEvent.PoseBookmark) val newBookmarkStatus = !state.pose.isBookmarked viewModelScope.launch { bookmarkRequests.emit(newBookmarkStatus) } reduce { copy(pose = pose.copy(isBookmarked = newBookmarkStatus)) } diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt index 55c7a4842..1367a75eb 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf @@ -57,6 +58,10 @@ internal fun PoseRoute( val lazyState = rememberLazyStaggeredGridState() val coroutineScope = rememberCoroutineScope() + LaunchedEffect(Unit) { + viewModel.logPoseView() + } + viewModel.store.sideEffects.collectWithLifecycle { sideEffect -> when (sideEffect) { PoseEffect.NavigateToNotification -> navigateToNotification() diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt index 2e5606a0d..62d5c3e6e 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/main/PoseViewModel.kt @@ -6,6 +6,8 @@ import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map +import com.neki.android.core.analytics.event.PoseAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.dataapi.repository.PoseRepository import com.neki.android.core.model.PeopleCount import com.neki.android.core.model.Pose @@ -26,6 +28,7 @@ import javax.inject.Inject @HiltViewModel internal class PoseViewModel @Inject constructor( private val poseRepository: PoseRepository, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { private val _headCountFilter = MutableStateFlow(null) @@ -71,6 +74,10 @@ internal class PoseViewModel @Inject constructor( onIntent = ::onIntent, ) + fun logPoseView() { + analyticsLogger.log(PoseAnalyticsEvent.PoseView) + } + private fun onIntent( intent: PoseIntent, state: PoseState, @@ -79,7 +86,7 @@ internal class PoseViewModel @Inject constructor( ) { when (intent) { // Pose Main - PoseIntent.EnterPoseScreen -> Unit + PoseIntent.EnterPoseScreen -> {} PoseIntent.ClickAlarmIcon -> postSideEffect(PoseEffect.NavigateToNotification) PoseIntent.ClickQRScanIcon -> postSideEffect(PoseEffect.NavigateToQRScan) PoseIntent.ClickPeopleCountChip -> reduce { copy(isShowPeopleCountBottomSheet = true) } @@ -87,6 +94,7 @@ internal class PoseViewModel @Inject constructor( PoseIntent.DismissPeopleCountBottomSheet -> reduce { copy(isShowPeopleCountBottomSheet = false) } PoseIntent.DismissRandomPosePeopleCountBottomSheet -> reduce { copy(isShowRandomPosePeopleCountBottomSheet = false) } PoseIntent.ClickBookmarkChip -> { + analyticsLogger.log(PoseAnalyticsEvent.PoseBookmarkFilter) val newValue = !state.isShowBookmarkedPose _isBookmarkOnly.value = newValue _headCountFilter.value = null @@ -117,6 +125,7 @@ internal class PoseViewModel @Inject constructor( } is PoseIntent.ClickBookmarkIcon -> { + analyticsLogger.log(PoseAnalyticsEvent.PoseBookmark) val pose = intent.pose val newBookmarked = !pose.isBookmarked updatedBookmarks.update { it + (pose.id to newBookmarked) } @@ -152,6 +161,7 @@ internal class PoseViewModel @Inject constructor( ) } } else { + analyticsLogger.log(PoseAnalyticsEvent.PoseFilterToggle(peopleCount = intent.peopleCount.value)) _headCountFilter.value = intent.peopleCount reduce { copy( diff --git a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt index 24d3c7a22..be2c6cbef 100644 --- a/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt +++ b/feature/pose/impl/src/main/java/com/neki/android/feature/pose/impl/random/RandomPoseViewModel.kt @@ -2,6 +2,8 @@ package com.neki.android.feature.pose.impl.random import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.analytics.event.PoseAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.common.coroutine.di.ApplicationScope import com.neki.android.core.common.exception.ApiErrorCode.NO_MORE_RANDOM_POSE import com.neki.android.core.common.exception.NoMorePoseException @@ -29,9 +31,11 @@ internal class RandomPoseViewModel @AssistedInject constructor( private val poseRepository: PoseRepository, private val userRepository: UserRepository, @ApplicationScope private val applicationScope: CoroutineScope, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { private val bookmarkJobs = mutableMapOf() + private var totalSwipeCount = 0 @AssistedFactory interface Factory { @@ -55,7 +59,10 @@ internal class RandomPoseViewModel @AssistedInject constructor( postSideEffect: (RandomPoseEffect) -> Unit, ) { when (intent) { - RandomPoseIntent.EnterRandomPoseScreen -> fetchInitialData(state, reduce, postSideEffect) + RandomPoseIntent.EnterRandomPoseScreen -> { + analyticsLogger.log(PoseAnalyticsEvent.PoseRandomStart) + fetchInitialData(state, reduce, postSideEffect) + } // 튜토리얼 RandomPoseIntent.ClickLeftSwipe -> { @@ -69,6 +76,7 @@ internal class RandomPoseViewModel @AssistedInject constructor( } is RandomPoseIntent.PageChanged -> { + totalSwipeCount++ reduce { copy(currentPage = intent.page) } prefetchIfNeeded(reduce) } @@ -84,6 +92,7 @@ internal class RandomPoseViewModel @AssistedInject constructor( } RandomPoseIntent.ClickBookmarkIcon -> { + analyticsLogger.log(PoseAnalyticsEvent.PoseBookmark) val currentPost = state.currentPose ?: return handleBookmarkToggle(currentPost.id, !currentPost.isBookmarked, reduce) } @@ -223,6 +232,8 @@ internal class RandomPoseViewModel @AssistedInject constructor( super.onCleared() val state = store.uiState.value + analyticsLogger.log(PoseAnalyticsEvent.PoseRandomSessionEnd(totalSwipeCount = totalSwipeCount)) + state.poseList.forEach { pose -> val currentBookmark = pose.isBookmarked val committedBookmark = state.committedBookmarks[pose.id] diff --git a/feature/select-album/api/src/main/java/com/neki/android/feature/select_album/api/SelectAlbumAction.kt b/feature/select-album/api/src/main/java/com/neki/android/feature/select_album/api/SelectAlbumAction.kt index 039d32304..953eb579d 100644 --- a/feature/select-album/api/src/main/java/com/neki/android/feature/select_album/api/SelectAlbumAction.kt +++ b/feature/select-album/api/src/main/java/com/neki/android/feature/select_album/api/SelectAlbumAction.kt @@ -24,6 +24,6 @@ sealed interface SelectAlbumAction { @Serializable data class CopyPhotos( val photoIds: List, - val withShowToast: Boolean = true, + val fromPhotoDetail: Boolean, ) : SelectAlbumAction } diff --git a/feature/select-album/impl/src/main/java/com/neki/android/feature/select_album/impl/SelectAlbumViewModel.kt b/feature/select-album/impl/src/main/java/com/neki/android/feature/select_album/impl/SelectAlbumViewModel.kt index fd4f53b63..b43f33975 100644 --- a/feature/select-album/impl/src/main/java/com/neki/android/feature/select_album/impl/SelectAlbumViewModel.kt +++ b/feature/select-album/impl/src/main/java/com/neki/android/feature/select_album/impl/SelectAlbumViewModel.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.neki.android.core.analytics.event.ArchiveAnalyticsEvent +import com.neki.android.core.analytics.logger.AnalyticsLogger import com.neki.android.core.dataapi.repository.FolderRepository import com.neki.android.core.dataapi.repository.PhotoRepository import com.neki.android.core.domain.usecase.UploadMultiplePhotoUseCase @@ -34,6 +36,7 @@ class SelectAlbumViewModel @AssistedInject constructor( private val folderRepository: FolderRepository, private val uploadSinglePhotoUseCase: UploadSinglePhotoUseCase, private val uploadMultiplePhotoUseCase: UploadMultiplePhotoUseCase, + private val analyticsLogger: AnalyticsLogger, ) : ViewModel() { @AssistedFactory @@ -129,18 +132,33 @@ class SelectAlbumViewModel @AssistedInject constructor( reduce { copy(isUploading = false) } when (action) { is SelectAlbumAction.UploadFromQR, is SelectAlbumAction.UploadFromGallery -> { + analyticsLogger.log( + ArchiveAnalyticsEvent.PhotoUpload( + method = when (action) { + is SelectAlbumAction.UploadFromQR -> "qr" + is SelectAlbumAction.UploadFromGallery -> "gallery" + }, + count = photoCount, + ), + ) + postSideEffect(SelectAlbumSideEffect.ShowToastMessage("이미지를 추가했어요")) postSideEffect(SelectAlbumSideEffect.SendUploadResult(albums.first())) } is SelectAlbumAction.MovePhotos -> { + analyticsLogger.log(ArchiveAnalyticsEvent.PhotoMove) postSideEffect(SelectAlbumSideEffect.ShowToastMessage("사진을 앨범에 이동했어요")) postSideEffect(SelectAlbumSideEffect.SendPhotoMovedResult) postSideEffect(SelectAlbumSideEffect.NavigateBack) } is SelectAlbumAction.CopyPhotos -> { - if (action.withShowToast) { + if (action.fromPhotoDetail) { + analyticsLogger.log(ArchiveAnalyticsEvent.AlbumAddFromDetail(albumCount = albums.size)) + } else { + analyticsLogger.log(ArchiveAnalyticsEvent.PhotoCopy) + analyticsLogger.log(ArchiveAnalyticsEvent.AlbumAddFromMulti(photoCount = action.photoIds.size, albumCount = albums.size)) postSideEffect(SelectAlbumSideEffect.ShowToastMessage("사진을 앨범에 추가했어요")) } postSideEffect(SelectAlbumSideEffect.SendPhotoCopiedResult(targetFolderIds, albums.first().title)) @@ -211,6 +229,7 @@ class SelectAlbumViewModel @AssistedInject constructor( viewModelScope.launch { folderRepository.createFolder(name = albumName) .onSuccess { + analyticsLogger.log(ArchiveAnalyticsEvent.AlbumCreate) fetchFolders(reduce) reduce { copy( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 987aa7a0d..4165001b6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -49,6 +49,7 @@ firebaseCrashlyticsPlugin = "3.0.6" androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "cameraX" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "cameraX" } androidx-camera-compose = { module = "androidx.camera:camera-compose", version.ref = "cameraX" } +guava = { module = "com.google.guava:guava", version = "33.4.8-android" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" } diff --git a/settings.gradle.kts b/settings.gradle.kts index c86ea5875..92a0872a1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,6 +33,8 @@ include(":core:data") include(":core:data-api") include(":core:model") include(":core:navigation") +include(":core:ui") +include(":core:analytics") include(":feature:auth:api") include(":feature:auth:impl") include(":feature:pose:api") @@ -47,4 +49,3 @@ include(":feature:photo-upload:api") include(":feature:photo-upload:impl") include(":feature:select-album:api") include(":feature:select-album:impl") -include(":core:ui")