diff --git a/app/src/main/java/com/eatssu/android/App.kt b/app/src/main/java/com/eatssu/android/App.kt index 54853df20..8eb2b72c4 100644 --- a/app/src/main/java/com/eatssu/android/App.kt +++ b/app/src/main/java/com/eatssu/android/App.kt @@ -8,8 +8,6 @@ import com.google.firebase.analytics.ktx.analytics import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.ktx.Firebase import com.kakao.sdk.common.KakaoSdk -import com.posthog.android.PostHogAndroid -import com.posthog.android.PostHogAndroidConfig import dagger.hilt.android.HiltAndroidApp import timber.log.Timber import javax.inject.Inject @@ -36,26 +34,6 @@ class App : Application(), Configuration.Provider { Firebase.analytics.setAnalyticsCollectionEnabled(true) } - setupPostHog() - } - - private fun setupPostHog() { - // Create a PostHog Config with the given API key and host - val config = PostHogAndroidConfig( - apiKey = BuildConfig.POSTHOG_API_KEY, - host = BuildConfig.POSTHOG_HOST, - ).apply { - sessionReplay = true - sessionReplayConfig.screenshot = true - if (BuildConfig.DEBUG) { - sessionReplayConfig.maskAllTextInputs = false - sessionReplayConfig.maskAllImages = false - } - } - - - // Setup PostHog with the given Context and Config - PostHogAndroid.setup(this, config) } override val workManagerConfiguration: Configuration diff --git a/app/src/main/java/com/eatssu/android/analytics/AnalyticsIdentityManager.kt b/app/src/main/java/com/eatssu/android/analytics/AnalyticsIdentityManager.kt new file mode 100644 index 000000000..f6556e3e1 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/analytics/AnalyticsIdentityManager.kt @@ -0,0 +1,46 @@ +package com.eatssu.android.analytics + +import com.eatssu.android.domain.model.College +import com.eatssu.android.domain.model.Department +import com.eatssu.common.analytics.AnalyticsIdentity +import com.eatssu.common.analytics.AnalyticsTracker +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class AnalyticsIdentityManager @Inject constructor( + private val analyticsTracker: AnalyticsTracker, +) { + + fun identifyUser( + email: String, + nickname: String? = null, + college: College? = null, + department: Department? = null, + ) { + val trimmedEmail = email.trim() + if (trimmedEmail.isBlank()) return + val selectedCollege = + college?.takeUnless { it.collegeId == -1 || it.collegeName.isBlank() || it.collegeName == "단과대" } + val selectedDepartment = + department?.takeUnless { + it.departmentId == -1 || it.departmentName.isBlank() || it.departmentName == "학과" + } + + analyticsTracker.identify( + AnalyticsIdentity( + distinctId = trimmedEmail, + email = trimmedEmail, + nickname = nickname?.trim()?.takeIf(String::isNotBlank), + collegeId = selectedCollege?.collegeId, + collegeName = selectedCollege?.collegeName, + departmentId = selectedDepartment?.departmentId, + departmentName = selectedDepartment?.departmentName, + ), + ) + } + + fun resetIdentity() { + analyticsTracker.resetIdentity() + } +} diff --git a/app/src/main/java/com/eatssu/android/analytics/AnalyticsIdentityProperties.kt b/app/src/main/java/com/eatssu/android/analytics/AnalyticsIdentityProperties.kt new file mode 100644 index 000000000..8dd4acc92 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/analytics/AnalyticsIdentityProperties.kt @@ -0,0 +1,13 @@ +package com.eatssu.android.analytics + +import com.eatssu.common.analytics.AnalyticsIdentity + +internal fun AnalyticsIdentity.toProperties(): Map = + buildMap { + put("email", email) + nickname?.let { put("nickname", it) } + collegeId?.let { put("college_id", it) } + collegeName?.let { put("college_name", it) } + departmentId?.let { put("department_id", it) } + departmentName?.let { put("department_name", it) } + } diff --git a/app/src/main/java/com/eatssu/android/analytics/ComposeAnalytics.kt b/app/src/main/java/com/eatssu/android/analytics/ComposeAnalytics.kt new file mode 100644 index 000000000..e73444e0d --- /dev/null +++ b/app/src/main/java/com/eatssu/android/analytics/ComposeAnalytics.kt @@ -0,0 +1,31 @@ +package com.eatssu.android.analytics + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import com.eatssu.common.analytics.AnalyticsEvent +import com.eatssu.common.analytics.AnalyticsIdentity +import com.eatssu.common.analytics.AnalyticsTracker + +val LocalAnalyticsTracker = staticCompositionLocalOf { NoOpAnalyticsTracker } + +@Composable +fun ProvideAnalyticsTracker( + analyticsTracker: AnalyticsTracker, + content: @Composable () -> Unit, +) { + CompositionLocalProvider( + LocalAnalyticsTracker provides analyticsTracker, + content = content, + ) +} + +private object NoOpAnalyticsTracker : AnalyticsTracker { + override val id: String = "noop" + + override fun track(event: AnalyticsEvent) = Unit + + override fun identify(identity: AnalyticsIdentity) = Unit + + override fun resetIdentity() = Unit +} diff --git a/app/src/main/java/com/eatssu/android/analytics/DefaultAnalyticsTracker.kt b/app/src/main/java/com/eatssu/android/analytics/DefaultAnalyticsTracker.kt new file mode 100644 index 000000000..3e1f00f05 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/analytics/DefaultAnalyticsTracker.kt @@ -0,0 +1,64 @@ +package com.eatssu.android.analytics + +import com.eatssu.common.analytics.AnalyticsEvent +import com.eatssu.common.analytics.AnalyticsIdentity +import com.eatssu.common.analytics.AnalyticsTracker +import javax.inject.Inject +import javax.inject.Singleton +import timber.log.Timber +import kotlin.jvm.JvmSuppressWildcards + +@Singleton +class DefaultAnalyticsTracker @Inject constructor( + trackers: Set<@JvmSuppressWildcards AnalyticsTracker>, +) : AnalyticsTracker { + + override val id: String = "default" + + private val trackers: List = trackers.distinctBy(AnalyticsTracker::id) + + override fun track(event: AnalyticsEvent) { + trackers.forEach { tracker -> + runCatching { + tracker.track(event) + }.onFailure { throwable -> + Timber.e( + throwable, + "Failed to track analytics event %s via %s", + event.eventName, + tracker.id, + ) + } + } + } + + override fun identify(identity: AnalyticsIdentity) { + if (identity.distinctId.isBlank()) return + + trackers.forEach { tracker -> + runCatching { + tracker.identify(identity) + }.onFailure { throwable -> + Timber.e( + throwable, + "Failed to identify analytics user via %s", + tracker.id, + ) + } + } + } + + override fun resetIdentity() { + trackers.forEach { tracker -> + runCatching { + tracker.resetIdentity() + }.onFailure { throwable -> + Timber.e( + throwable, + "Failed to reset analytics identity via %s", + tracker.id, + ) + } + } + } +} diff --git a/app/src/main/java/com/eatssu/android/analytics/FirebaseAnalyticsTracker.kt b/app/src/main/java/com/eatssu/android/analytics/FirebaseAnalyticsTracker.kt new file mode 100644 index 000000000..0b5af9513 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/analytics/FirebaseAnalyticsTracker.kt @@ -0,0 +1,46 @@ +package com.eatssu.android.analytics + +import android.os.Bundle +import com.eatssu.common.analytics.AnalyticsEvent +import com.eatssu.common.analytics.AnalyticsIdentity +import com.eatssu.common.analytics.AnalyticsTracker +import com.google.firebase.analytics.FirebaseAnalytics +import javax.inject.Inject + +class FirebaseAnalyticsTracker @Inject constructor( + private val firebaseAnalytics: FirebaseAnalytics, +) : AnalyticsTracker { + override val id: String = "firebase" + + override fun track(event: AnalyticsEvent) { + val payload = event.toPayload() + firebaseAnalytics.logEvent(payload.eventName, payload.properties.toBundle()) + } + + override fun identify(identity: AnalyticsIdentity) { + firebaseAnalytics.setUserId(identity.distinctId) + identity.toProperties().forEach { (key, value) -> + firebaseAnalytics.setUserProperty(key, value.toString()) + } + } + + override fun resetIdentity() { + firebaseAnalytics.setUserId(null) + } +} + +internal fun Map.toBundle(): Bundle = + Bundle().apply { + 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 Float -> putFloat(key, value) + is Boolean -> putBoolean(key, value) + is Bundle -> putBundle(key, value) + else -> putString(key, value.toString()) + } + } + } diff --git a/app/src/main/java/com/eatssu/android/analytics/PostHogAnalyticsTracker.kt b/app/src/main/java/com/eatssu/android/analytics/PostHogAnalyticsTracker.kt new file mode 100644 index 000000000..0d8d0ecf6 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/analytics/PostHogAnalyticsTracker.kt @@ -0,0 +1,32 @@ +package com.eatssu.android.analytics + +import com.eatssu.common.analytics.AnalyticsEvent +import com.eatssu.common.analytics.AnalyticsIdentity +import com.eatssu.common.analytics.AnalyticsTracker +import com.posthog.PostHogInterface +import javax.inject.Inject + +class PostHogAnalyticsTracker @Inject constructor( + private val postHog: PostHogInterface, +) : AnalyticsTracker { + override val id: String = "posthog" + + override fun track(event: AnalyticsEvent) { + val payload = event.toPayload() + postHog.capture( + event = payload.eventName, + properties = payload.properties.takeIf { it.isNotEmpty() }, + ) + } + + override fun identify(identity: AnalyticsIdentity) { + postHog.identify( + distinctId = identity.distinctId, + userProperties = identity.toProperties().takeIf { it.isNotEmpty() }, + ) + } + + override fun resetIdentity() { + postHog.reset() + } +} diff --git a/app/src/main/java/com/eatssu/android/di/AnalyticsModule.kt b/app/src/main/java/com/eatssu/android/di/AnalyticsModule.kt new file mode 100644 index 000000000..65a826ec7 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/di/AnalyticsModule.kt @@ -0,0 +1,68 @@ +package com.eatssu.android.di + +import android.content.Context +import com.eatssu.android.BuildConfig +import com.eatssu.android.analytics.DefaultAnalyticsTracker +import com.eatssu.android.analytics.FirebaseAnalyticsTracker +import com.eatssu.android.analytics.PostHogAnalyticsTracker +import com.eatssu.common.analytics.AnalyticsTracker +import com.google.firebase.analytics.FirebaseAnalytics +import com.google.firebase.analytics.ktx.analytics +import com.google.firebase.ktx.Firebase +import com.posthog.PostHogInterface +import com.posthog.android.PostHogAndroid +import com.posthog.android.PostHogAndroidConfig +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AnalyticsModule { + + @Provides + @Singleton + fun provideAnalyticsTracker( + defaultAnalyticsTracker: DefaultAnalyticsTracker, + ): AnalyticsTracker = defaultAnalyticsTracker + + @Provides + @IntoSet + fun provideFirebaseAnalyticsTracker( + firebaseAnalyticsTracker: FirebaseAnalyticsTracker, + ): AnalyticsTracker = firebaseAnalyticsTracker + + @Provides + @IntoSet + fun providePostHogAnalyticsTracker( + postHogAnalyticsTracker: PostHogAnalyticsTracker, + ): AnalyticsTracker = postHogAnalyticsTracker + + @Provides + @Singleton + fun provideFirebaseAnalytics(): FirebaseAnalytics { + return Firebase.analytics + } + + @Provides + @Singleton + fun providePostHog(context: Context): PostHogInterface { + val config = PostHogAndroidConfig( + apiKey = BuildConfig.POSTHOG_API_KEY, + host = BuildConfig.POSTHOG_HOST, + ).apply { + sessionReplay = true + sessionReplayConfig.screenshot = true + debug = BuildConfig.DEBUG + if (BuildConfig.DEBUG) { + sessionReplayConfig.maskAllTextInputs = false + sessionReplayConfig.maskAllImages = false + } + } + + return PostHogAndroid.with(context, config) + } +} diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt index 6edd9599c..a49369b9b 100644 --- a/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt +++ b/app/src/main/java/com/eatssu/android/domain/usecase/auth/LogoutUseCase.kt @@ -3,16 +3,19 @@ package com.eatssu.android.domain.usecase.auth import com.eatssu.android.data.local.AccountDataStore import com.eatssu.android.data.local.SettingDataStore import com.eatssu.android.data.local.TokenStore +import com.eatssu.common.analytics.AnalyticsTracker import javax.inject.Inject class LogoutUseCase @Inject constructor( private val accountDataStore: AccountDataStore, private val tokenStore: TokenStore, private val settingDataStore: SettingDataStore, + private val analyticsTracker: AnalyticsTracker, ) { suspend operator fun invoke() { accountDataStore.clear() tokenStore.clear() settingDataStore.clear() + analyticsTracker.resetIdentity() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/domain/usecase/user/GetUserEmailUseCase.kt b/app/src/main/java/com/eatssu/android/domain/usecase/user/GetUserEmailUseCase.kt new file mode 100644 index 000000000..7c2463e27 --- /dev/null +++ b/app/src/main/java/com/eatssu/android/domain/usecase/user/GetUserEmailUseCase.kt @@ -0,0 +1,11 @@ +package com.eatssu.android.domain.usecase.user + +import com.eatssu.android.data.local.AccountDataStore +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +class GetUserEmailUseCase @Inject constructor( + private val accountDataStore: AccountDataStore, +) { + suspend operator fun invoke(): String = accountDataStore.email.first() +} diff --git a/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt index 909b704ef..eace8c24b 100644 --- a/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt @@ -5,9 +5,11 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.eatssu.android.R +import com.eatssu.android.analytics.AnalyticsIdentityManager import com.eatssu.android.domain.repository.UserRepository import com.eatssu.android.domain.usecase.auth.LogoutUseCase import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase +import com.eatssu.android.domain.usecase.user.GetUserEmailUseCase import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase import com.eatssu.common.UiEvent @@ -31,7 +33,9 @@ class MainViewModel @Inject constructor( private val getUserNickNameUseCase: GetUserNickNameUseCase, private val setUserCollegeDepartmentUseCase: SetUserCollegeDepartmentUseCase, private val userRepository: UserRepository, - private val getUserCollegeDepartmentUseCase: GetUserCollegeDepartmentUseCase + private val getUserCollegeDepartmentUseCase: GetUserCollegeDepartmentUseCase, + private val getUserEmailUseCase: GetUserEmailUseCase, + private val analyticsIdentityManager: AnalyticsIdentityManager, ) : ViewModel() { private val _uiState: MutableStateFlow> = MutableStateFlow(UiState.Init) @@ -65,7 +69,10 @@ class MainViewModel @Inject constructor( if (nickname.isBlank()) { _uiState.value = UiState.Success(MainState.NicknameNull) _uiEvent.emit(UiEvent.ShowToast(UiText.StringResource(R.string.set_nickname), ToastType.ERROR)) + return } + + syncAnalyticsIdentity() } fun logOut() { @@ -115,6 +122,7 @@ class MainViewModel @Inject constructor( } setUserCollegeDepartmentUseCase(college, department) + syncAnalyticsIdentity() _uiState.value = UiState.Success( MainState.DepartmentState( @@ -125,6 +133,18 @@ class MainViewModel @Inject constructor( ) } + private suspend fun syncAnalyticsIdentity() { + val email = getUserEmailUseCase() + if (email.isBlank()) return + + val userInfo = getUserCollegeDepartmentUseCase() + analyticsIdentityManager.identifyUser( + email = email, + nickname = userInfo.nickname, + college = userInfo.userCollege, + department = userInfo.userDepartment, + ) + } } diff --git a/app/src/main/java/com/eatssu/android/presentation/base/BaseActivity.kt b/app/src/main/java/com/eatssu/android/presentation/base/BaseActivity.kt index b021f434b..be58de548 100644 --- a/app/src/main/java/com/eatssu/android/presentation/base/BaseActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/base/BaseActivity.kt @@ -21,11 +21,13 @@ import com.eatssu.android.presentation.common.NetworkConnection import com.eatssu.android.presentation.login.LoginActivity import com.eatssu.android.presentation.util.observeNetworkError import com.eatssu.android.presentation.util.showInfoToast -import com.eatssu.common.EventLogger +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.ScreenViewEvent import com.eatssu.common.enums.ScreenId import com.google.android.material.card.MaterialCardView import kotlinx.coroutines.launch import timber.log.Timber +import javax.inject.Inject abstract class BaseActivity( @@ -33,6 +35,9 @@ abstract class BaseActivity( val screenId: ScreenId ) : AppCompatActivity() { + @Inject + protected lateinit var analyticsTracker: AnalyticsTracker + private var _binding: B? = null val binding get() = _binding!! @@ -152,7 +157,7 @@ abstract class BaseActivity( super.onResume() if (shouldLogScreenId()) { - EventLogger.screenView(screenId) + analyticsTracker.track(ScreenViewEvent(screenId)) Timber.d("screen view logging: $screenId") } } diff --git a/app/src/main/java/com/eatssu/android/presentation/base/BaseFragment.kt b/app/src/main/java/com/eatssu/android/presentation/base/BaseFragment.kt index 780f0db1a..ddc9da393 100644 --- a/app/src/main/java/com/eatssu/android/presentation/base/BaseFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/base/BaseFragment.kt @@ -6,14 +6,19 @@ import android.view.View import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding -import com.eatssu.common.EventLogger +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.ScreenViewEvent import com.eatssu.common.enums.ScreenId import timber.log.Timber +import javax.inject.Inject abstract class BaseFragment( val screenId: ScreenId ) : Fragment() { + @Inject + protected lateinit var analyticsTracker: AnalyticsTracker + private var _binding: B? = null val binding get() = _binding!! @@ -35,7 +40,7 @@ abstract class BaseFragment( override fun onResume() { super.onResume() - EventLogger.screenView(screenId) + analyticsTracker.track(ScreenViewEvent(screenId)) Timber.d("screen view logging: $screenId") } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/CafeteriaFragment.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/CafeteriaFragment.kt index 16a2de5e7..861d96f07 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/CafeteriaFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/CafeteriaFragment.kt @@ -16,7 +16,7 @@ import com.eatssu.android.presentation.cafeteria.calendar.CalendarAdapter import com.eatssu.android.presentation.util.CalendarUtil import com.eatssu.android.presentation.util.CalendarUtil.daysInWeekArray import com.eatssu.android.presentation.util.CalendarUtil.monthYearFromDate -import com.eatssu.common.EventLogger +import com.eatssu.common.analytics.CafeteriaAnalyticsEvent import com.eatssu.common.enums.ScreenId import com.eatssu.common.enums.Time import com.google.android.material.tabs.TabLayout @@ -74,7 +74,7 @@ class CafeteriaFragment : BaseFragment( 2 -> Time.DINNER else -> Time.LUNCH // 기본값 } - EventLogger.selectMealTime(time) + analyticsTracker.track(CafeteriaAnalyticsEvent.MealTimeSelected(time)) } }) } @@ -113,6 +113,6 @@ class CafeteriaFragment : BaseFragment( mainViewModel.setData(date) mainPosition = position setWeekView() - EventLogger.selectDay(date.dayOfWeek.name) + analyticsTracker.track(CafeteriaAnalyticsEvent.DaySelected(date.dayOfWeek.name)) } } diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/info/InfoBottomSheetFragment.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/info/InfoBottomSheetFragment.kt index 2bdeaed0f..8db67db91 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/info/InfoBottomSheetFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/info/InfoBottomSheetFragment.kt @@ -8,14 +8,22 @@ import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.bumptech.glide.Glide import com.eatssu.android.databinding.FragmentBottomsheetInfoBinding -import com.eatssu.common.EventLogger +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.CafeteriaAnalyticsEvent +import com.eatssu.common.analytics.ScreenViewEvent import com.eatssu.common.enums.Restaurant import com.eatssu.common.enums.ScreenId import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import timber.log.Timber +import javax.inject.Inject +@AndroidEntryPoint class InfoBottomSheetFragment : BottomSheetDialogFragment() { + @Inject + lateinit var analyticsTracker: AnalyticsTracker + private var _binding: FragmentBottomsheetInfoBinding? = null private val binding get() = _binding!! @@ -37,7 +45,7 @@ class InfoBottomSheetFragment : BottomSheetDialogFragment() { val restaurantType = enumValues().find { it.name == name } ?: Restaurant.HAKSIK Timber.d("onViewCreated: $name $restaurantType") - EventLogger.clickRestaurantInfo(restaurantType) + analyticsTracker.track(CafeteriaAnalyticsEvent.RestaurantInfoClicked(restaurantType)) binding.tvName.text = getString(restaurantType.displayNameResId) @@ -59,7 +67,7 @@ class InfoBottomSheetFragment : BottomSheetDialogFragment() { override fun onResume() { super.onResume() - EventLogger.screenView(ScreenId.HOME_INFO) + analyticsTracker.track(ScreenViewEvent(ScreenId.HOME_INFO)) } override fun onDestroyView() { diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuAdapter.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuAdapter.kt index ca07e3e29..546f234dc 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuAdapter.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuAdapter.kt @@ -11,10 +11,12 @@ import com.eatssu.android.R import com.eatssu.android.databinding.ItemCafeteriaSectionBinding import com.eatssu.android.domain.model.Section import com.eatssu.android.presentation.cafeteria.info.InfoBottomSheetFragment +import com.eatssu.common.analytics.AnalyticsTracker class MenuAdapter( private val fragmentManager: FragmentManager, - private val sectionList: List
+ private val sectionList: List
, + private val analyticsTracker: AnalyticsTracker, ) : RecyclerView.Adapter() { class MyViewHolder( @@ -23,7 +25,8 @@ class MenuAdapter( fun bind( fragmentManager: FragmentManager, - sectionModel: Section + sectionModel: Section, + analyticsTracker: AnalyticsTracker, ) { binding.llCafeteriaInfo.setOnClickListener { @@ -45,7 +48,7 @@ class MenuAdapter( setHasFixedSize(true) layoutManager = LinearLayoutManager(binding.root.context) adapter = sectionModel.menuList?.let { - MenuSubAdapter(it, sectionModel.cafeteria) + MenuSubAdapter(it, sectionModel.cafeteria, analyticsTracker) } } } @@ -60,10 +63,10 @@ class MenuAdapter( override fun onBindViewHolder(holder: MyViewHolder, position: Int) { sectionList[position].let { sectionModel -> - holder.bind(fragmentManager, sectionModel) + holder.bind(fragmentManager, sectionModel, analyticsTracker) } } override fun getItemCount(): Int = sectionList.size -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt index 8e9f3d374..9509e8595 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuFragment.kt @@ -17,6 +17,7 @@ import com.eatssu.android.databinding.FragmentMenuBinding import com.eatssu.android.domain.model.Section import com.eatssu.android.presentation.MainViewModel import com.eatssu.android.presentation.cafeteria.info.InfoViewModel +import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.common.UiState import com.eatssu.common.enums.MenuType import com.eatssu.common.enums.Restaurant @@ -26,9 +27,13 @@ import kotlinx.coroutines.launch import timber.log.Timber import java.time.DayOfWeek import java.time.format.DateTimeFormatter +import javax.inject.Inject @AndroidEntryPoint class MenuFragment : Fragment() { + @Inject + lateinit var analyticsTracker: AnalyticsTracker + private var _binding: FragmentMenuBinding? = null private val binding get() = _binding!! @@ -133,7 +138,7 @@ class MenuFragment : Fragment() { binding.rv.apply { setHasFixedSize(true) layoutManager = LinearLayoutManager(context) - adapter = MenuAdapter(getParentFragmentManager(), sectionList) + adapter = MenuAdapter(getParentFragmentManager(), sectionList, analyticsTracker) } } @@ -141,4 +146,4 @@ class MenuFragment : Fragment() { super.onDestroyView() _binding = null } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuSubAdapter.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuSubAdapter.kt index d64b3edb8..b67331ed5 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuSubAdapter.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/menu/MenuSubAdapter.kt @@ -10,7 +10,8 @@ import androidx.recyclerview.widget.RecyclerView import com.eatssu.android.databinding.ItemMenuBinding import com.eatssu.android.domain.model.Menu import com.eatssu.android.presentation.cafeteria.review.ReviewComposeActivity -import com.eatssu.common.EventLogger +import com.eatssu.common.analytics.CafeteriaAnalyticsEvent +import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.common.enums.MenuType import com.eatssu.common.enums.Restaurant @@ -18,6 +19,7 @@ import com.eatssu.common.enums.Restaurant class MenuSubAdapter( private val dataList: List, private val restaurant: Restaurant, + private val analyticsTracker: AnalyticsTracker, ) : RecyclerView.Adapter() { @@ -58,7 +60,7 @@ class MenuSubAdapter( } } ContextCompat.startActivity(binding.root.context, intent, null) - EventLogger.clickMenu(restaurant) + analyticsTracker.track(CafeteriaAnalyticsEvent.MenuClicked(restaurant)) // Re-enable after short delay binding.root.postDelayed({ binding.root.isEnabled = true }, 800) } diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewComposeActivity.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewComposeActivity.kt index 30ceb6a2a..d25f81b1e 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewComposeActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewComposeActivity.kt @@ -19,16 +19,22 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.navigation.compose.rememberNavController +import com.eatssu.android.analytics.ProvideAnalyticsTracker +import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.common.enums.MenuType import com.eatssu.design_system.theme.EatssuTheme import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber +import javax.inject.Inject import kotlin.properties.Delegates @AndroidEntryPoint class ReviewComposeActivity : ComponentActivity() { + @Inject + lateinit var analyticsTracker: AnalyticsTracker + private var menuType: String? = null private var itemId by Delegates.notNull() private lateinit var itemName: String @@ -38,23 +44,25 @@ class ReviewComposeActivity : ComponentActivity() { getIntents() // 컴포즈 화면 그리기 전에 호출 setContent { - EatssuTheme { - val navHostController = rememberNavController() - val parsedMenuType = MenuType.entries.find { it.name == menuType } + ProvideAnalyticsTracker(analyticsTracker) { + EatssuTheme { + val navHostController = rememberNavController() + val parsedMenuType = MenuType.entries.find { it.name == menuType } - parsedMenuType?.let { type -> - ReviewNav( - navHostController = navHostController, - menuType = type, - menuName = itemName, - id = itemId, - onExit = { finish() } - ) - } ?: run { - Timber.e("Invalid or null MenuType received: $menuType") - ErrorScreen( - onBackClick = { finish() } - ) + parsedMenuType?.let { type -> + ReviewNav( + navHostController = navHostController, + menuType = type, + menuName = itemName, + id = itemId, + onExit = { finish() } + ) + } ?: run { + Timber.e("Invalid or null MenuType received: $menuType") + ErrorScreen( + onBackClick = { finish() } + ) + } } } } @@ -95,4 +103,4 @@ class ReviewComposeActivity : ComponentActivity() { ErrorScreen(onBackClick = {}) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt index 39e4803ee..b25b1f3ae 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/list/ReviewListScreen.kt @@ -44,6 +44,7 @@ import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import com.eatssu.android.R +import com.eatssu.android.analytics.LocalAnalyticsTracker import com.eatssu.android.domain.model.Review import com.eatssu.android.domain.model.ReviewInfo import com.eatssu.android.presentation.cafeteria.review.list.component.MyReviewBottomSheet @@ -53,10 +54,10 @@ import com.eatssu.android.presentation.cafeteria.review.list.component.ReviewPro import com.eatssu.android.presentation.cafeteria.review.report.ReportActivity import com.eatssu.android.presentation.util.TrackScreenViewEvent import com.eatssu.android.presentation.util.showToast -import com.eatssu.common.EventLogger import com.eatssu.common.UiEvent import com.eatssu.common.UiState import com.eatssu.common.UiText +import com.eatssu.common.analytics.ReviewAnalyticsEvent import com.eatssu.common.enums.MenuType import com.eatssu.common.enums.ScreenId import com.eatssu.common.enums.ToastType @@ -143,6 +144,7 @@ internal fun ReviewListScreen( onDeleteClick: (reviewId: Long) -> Unit, ) { val context = LocalContext.current + val analyticsTracker = LocalAnalyticsTracker.current var showMyBottomSheet by remember { mutableStateOf(false) } var showOthersBottomSheet by remember { mutableStateOf(false) } @@ -190,7 +192,7 @@ internal fun ReviewListScreen( text = stringResource(R.string.review_write), onClick = { onReviewWriteButtonClick() - EventLogger.writeReview() //작성 하러가기가 이벤트임 + analyticsTracker.track(ReviewAnalyticsEvent.WriteClicked) }, modifier = Modifier .padding(24.dp) diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt index 72faafcce..15dec9649 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModel.kt @@ -9,10 +9,11 @@ import com.eatssu.android.domain.model.MenuMini import com.eatssu.android.domain.usecase.menu.GetValidMenusOfMealUseCase import com.eatssu.android.domain.usecase.review.GetImageUrlUseCase import com.eatssu.android.domain.usecase.review.WriteReviewUseCase -import com.eatssu.common.EventLogger +import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.common.UiEvent import com.eatssu.common.UiState import com.eatssu.common.UiText +import com.eatssu.common.analytics.ReviewAnalyticsEvent import com.eatssu.common.enums.MenuType import com.eatssu.common.enums.ToastType import dagger.hilt.android.lifecycle.HiltViewModel @@ -33,6 +34,7 @@ class WriteReviewViewModel @Inject constructor( private val writeReviewUseCase: WriteReviewUseCase, private val getImageUrlUseCase: GetImageUrlUseCase, private val getValidMenusOfMealUseCase: GetValidMenusOfMealUseCase, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { private val _uiState = MutableStateFlow>(UiState.Init) @@ -160,10 +162,12 @@ class WriteReviewViewModel @Inject constructor( } // 리뷰 작성 완료 로깅 - EventLogger.completeReview( - rating = editing.rating.toLong(), - likes = editing.likedMenuIds.size.toLong(), - photoAttached = editing.selectedImageUri != null + analyticsTracker.track( + ReviewAnalyticsEvent.Completed( + rating = editing.rating.toLong(), + likes = editing.likedMenuIds.size.toLong(), + photoAttached = editing.selectedImageUri != null, + ), ) _uiEvent.emit(UiEvent.ShowToast(UiText.StringResource(R.string.toast_review_write_success), ToastType.SUCCESS)) diff --git a/app/src/main/java/com/eatssu/android/presentation/intro/IntroActivity.kt b/app/src/main/java/com/eatssu/android/presentation/intro/IntroActivity.kt index 35cd4af3b..37e84c616 100644 --- a/app/src/main/java/com/eatssu/android/presentation/intro/IntroActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/intro/IntroActivity.kt @@ -13,18 +13,24 @@ import com.eatssu.android.presentation.login.LoginActivity import com.eatssu.android.presentation.util.observeNetworkError import com.eatssu.android.presentation.util.showToast import com.eatssu.android.presentation.util.startActivity -import com.eatssu.common.EventLogger import com.eatssu.common.UiEvent import com.eatssu.common.UiState +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.AppAnalyticsEvent +import com.eatssu.common.analytics.ScreenViewEvent import com.eatssu.common.enums.LaunchPath import com.eatssu.common.enums.ScreenId import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import javax.inject.Inject @AndroidEntryPoint class IntroActivity : AppCompatActivity() { + @Inject + lateinit var analyticsTracker: AnalyticsTracker + private val introViewModel: IntroViewModel by viewModels() private lateinit var binding: ActivityIntroBinding @@ -94,21 +100,21 @@ class IntroActivity : AppCompatActivity() { private fun log() { val launchPath = intent.getStringExtra("launch_path") when (launchPath) { - "widget" -> EventLogger.appLaunch(LaunchPath.WIDGET) - "local_notification" -> EventLogger.appLaunch(LaunchPath.LOCAL_NOTIFICATION) - "remote_notification" -> EventLogger.appLaunch(LaunchPath.REMOTE_NOTIFICATION) + "widget" -> analyticsTracker.track(AppAnalyticsEvent.Launch(LaunchPath.WIDGET)) + "local_notification" -> analyticsTracker.track(AppAnalyticsEvent.Launch(LaunchPath.LOCAL_NOTIFICATION)) + "remote_notification" -> analyticsTracker.track(AppAnalyticsEvent.Launch(LaunchPath.REMOTE_NOTIFICATION)) // launch_path가 없으면 일반적인 앱 아이콘 클릭으로 간주 - else -> EventLogger.appLaunch(LaunchPath.ICON) + else -> analyticsTracker.track(AppAnalyticsEvent.Launch(LaunchPath.ICON)) } } override fun onResume() { super.onResume() - EventLogger.screenView(ScreenId.LOGIN_SPLASH) + analyticsTracker.track(ScreenViewEvent(ScreenId.LOGIN_SPLASH)) } private fun showForceUpdateDialog() { val intent = Intent(this, ForceUpdateDialogActivity::class.java) startActivity(intent) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt index 006d58a17..103c60be3 100644 --- a/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/login/LoginViewModel.kt @@ -3,6 +3,7 @@ package com.eatssu.android.presentation.login import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.eatssu.android.R +import com.eatssu.android.analytics.AnalyticsIdentityManager import com.eatssu.android.domain.usecase.auth.LoginUseCase import com.eatssu.android.domain.usecase.auth.SetAccessTokenUseCase import com.eatssu.android.domain.usecase.auth.SetRefreshTokenUseCase @@ -28,7 +29,8 @@ class LoginViewModel @Inject constructor( private val loginUseCase: LoginUseCase, private val setAccessTokenUseCase: SetAccessTokenUseCase, private val setRefreshTokenUseCase: SetRefreshTokenUseCase, - private val setUserEmailUseCase: SetUserEmailUseCase + private val setUserEmailUseCase: SetUserEmailUseCase, + private val analyticsIdentityManager: AnalyticsIdentityManager, ) : ViewModel() { private val _uiState = MutableStateFlow>(UiState.Init) @@ -55,6 +57,7 @@ class LoginViewModel @Inject constructor( setAccessTokenUseCase(token.accessToken) setRefreshTokenUseCase(token.refreshToken) setUserEmailUseCase(email) + analyticsIdentityManager.identifyUser(email = email) _uiState.value = UiState.Success(LoginState.LoginSuccess) } diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt index a87d4c8e3..1ca359c27 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragment.kt @@ -6,12 +6,18 @@ import android.view.View import android.view.ViewGroup import androidx.compose.ui.platform.ComposeView import androidx.fragment.app.Fragment +import com.eatssu.android.analytics.ProvideAnalyticsTracker +import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.design_system.theme.EatssuTheme import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MapFragment : Fragment() { + @Inject + lateinit var analyticsTracker: AnalyticsTracker + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -24,10 +30,12 @@ class MapFragment : Fragment() { // Inflate the layout for this fragment return ComposeView(requireContext()).apply { setContent { - EatssuTheme { - MapRoute() + ProvideAnalyticsTracker(analyticsTracker) { + EatssuTheme { + MapRoute() + } } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt index efb83efb6..24fb5a176 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapFragmentView.kt @@ -52,6 +52,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.eatssu.android.R +import com.eatssu.android.analytics.LocalAnalyticsTracker import com.eatssu.android.domain.model.Partnership import com.eatssu.android.presentation.MainState import com.eatssu.android.presentation.MainViewModel @@ -62,10 +63,10 @@ import com.eatssu.android.presentation.map.component.PartnershipFilterToggle import com.eatssu.android.presentation.mypage.userinfo.UserInfoActivity import com.eatssu.android.presentation.util.TrackScreenViewEvent import com.eatssu.android.presentation.util.showToast -import com.eatssu.common.EventLogger import com.eatssu.common.UiEvent import com.eatssu.common.UiState import com.eatssu.common.UiText +import com.eatssu.common.analytics.MapAnalyticsEvent import com.eatssu.common.enums.ScreenId import com.eatssu.common.enums.StoreType import com.eatssu.common.enums.ToastType @@ -287,6 +288,8 @@ internal fun MapScreen( departmentName: String?, selectedFilter: FilterType, ) { + val analyticsTracker = LocalAnalyticsTracker.current + Scaffold( topBar = { CenterAlignedTopAppBar( @@ -323,14 +326,16 @@ internal fun MapScreen( // 특정 식당에 대한 제휴 정보 BottomSheet if (partnershipSheetState.isVisible) { mapState.restaurantPartnershipInfo?.let { info -> - mapState.storeType?.let { storeType -> - - EventLogger.clickPartnerRestaurant( - college = collegeId, - major = departmentId, - partnerRestaurantId = info.id.toLong() - ) + LaunchedEffect(info.id, collegeId, departmentId) { + analyticsTracker.track( + MapAnalyticsEvent.PartnerRestaurantClicked( + college = collegeId, + major = departmentId, + partnerRestaurantId = info.id.toLong(), + ), + ) + } MapRestaurantBottomSheet( storeName = info.storeName, diff --git a/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt index 3a3dbe74a..1c93a58a3 100644 --- a/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/map/MapViewModel.kt @@ -10,9 +10,10 @@ import com.eatssu.android.domain.usecase.user.GetPartnershipDetailUseCase import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase import com.eatssu.android.presentation.map.component.FilterType import com.eatssu.android.presentation.map.model.RestaurantInfo -import com.eatssu.common.EventLogger import com.eatssu.common.UiEvent import com.eatssu.common.UiState +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.MapAnalyticsEvent import com.eatssu.common.enums.StoreType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -51,6 +52,7 @@ class MapViewModel @Inject constructor( private val partnershipRepository: PartnershipRepository, private val getPartnershipDetailUseCase: GetPartnershipDetailUseCase, private val getUserCollegeDepartmentUseCase: GetUserCollegeDepartmentUseCase, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { private val _uiState: MutableStateFlow> = MutableStateFlow(UiState.Init) @@ -125,12 +127,17 @@ class MapViewModel @Inject constructor( when (filter) { FilterType.All -> { loadPartnerships() - EventLogger.clickMap() + analyticsTracker.track(MapAnalyticsEvent.AllClicked) } FilterType.Mine -> { loadUserCollegePartnerships() - EventLogger.clickMapMine(_collegeId.value, _departmentId.value) + analyticsTracker.track( + MapAnalyticsEvent.MineClicked( + college = _collegeId.value, + major = _departmentId.value, + ), + ) } } } diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt index 3c70b24cf..74ea31efd 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/MyPageFragment.kt @@ -29,9 +29,9 @@ import com.eatssu.android.presentation.util.showDialog import com.eatssu.android.presentation.util.showErrorToast import com.eatssu.android.presentation.util.showInfoToast import com.eatssu.android.presentation.util.showToast -import com.eatssu.common.EventLogger import com.eatssu.common.UiEvent import com.eatssu.common.UiState +import com.eatssu.common.analytics.ScreenViewEvent import com.eatssu.common.enums.ScreenId import com.google.android.gms.oss.licenses.OssLicensesMenuActivity import com.kakao.sdk.common.util.KakaoCustomTabsClient @@ -151,8 +151,7 @@ class MyPageFragment : BaseFragment(ScreenId.MYPAGE_MAIN) val url = TalkApiClient.instance.chatChannelUrl(channelPublicId) KakaoCustomTabsClient.openWithDefault(context, url) } - - EventLogger.screenView(ScreenId.EXTERNAL_INQUIRE) + analyticsTracker.track(ScreenViewEvent(ScreenId.EXTERNAL_INQUIRE)) } binding.llMyReview.setOnClickListener { diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorActivity.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorActivity.kt index 6781df64c..3b94f8101 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/language/LanguageSelectorActivity.kt @@ -3,19 +3,27 @@ package com.eatssu.android.presentation.mypage.language import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import com.eatssu.android.analytics.ProvideAnalyticsTracker +import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.design_system.theme.EatssuTheme import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class LanguageSelectorActivity : ComponentActivity() { + @Inject + lateinit var analyticsTracker: AnalyticsTracker + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - EatssuTheme { - LanguageSelectorScreen( - onBack = { finish() } - ) + ProvideAnalyticsTracker(analyticsTracker) { + EatssuTheme { + LanguageSelectorScreen( + onBack = { finish() } + ) + } } } } diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListActivity.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListActivity.kt index bd9fe1a99..4826f6fe5 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/myreview/MyReviewListActivity.kt @@ -4,24 +4,32 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.navigation.compose.rememberNavController +import com.eatssu.android.analytics.ProvideAnalyticsTracker +import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.design_system.theme.EatssuTheme import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MyReviewListComposeActivity : ComponentActivity() { + @Inject + lateinit var analyticsTracker: AnalyticsTracker + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - EatssuTheme { - val navHostController = rememberNavController() + ProvideAnalyticsTracker(analyticsTracker) { + EatssuTheme { + val navHostController = rememberNavController() - MyReviewNav( - navHostController = navHostController, - onExit = { finish() } - ) + MyReviewNav( + navHostController = navHostController, + onExit = { finish() } + ) + } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/terms/WebViewActivity.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/terms/WebViewActivity.kt index 96dc82131..11ce04176 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/terms/WebViewActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/terms/WebViewActivity.kt @@ -8,10 +8,12 @@ import android.widget.ImageView import com.eatssu.android.R import com.eatssu.android.databinding.ActivityWebviewBinding import com.eatssu.android.presentation.base.BaseActivity -import com.eatssu.common.EventLogger +import com.eatssu.common.analytics.ScreenViewEvent import com.eatssu.common.enums.ScreenId +import dagger.hilt.android.AndroidEntryPoint import timber.log.Timber +@AndroidEntryPoint class WebViewActivity : BaseActivity( ActivityWebviewBinding::inflate, @@ -92,7 +94,7 @@ class WebViewActivity : val screenIdString = intent.getStringExtra("SCREEN_ID") ?: return val screenId = ScreenId.entries.find { it.name == screenIdString } ?: return - EventLogger.screenView(screenId) + analyticsTracker.track(ScreenViewEvent(screenId)) Timber.d("WebViewActivity screen view logging: $screenId") } diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt index 3f7d25697..cce319e94 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModel.kt @@ -3,10 +3,12 @@ package com.eatssu.android.presentation.mypage.userinfo import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.eatssu.android.R +import com.eatssu.android.analytics.AnalyticsIdentityManager import com.eatssu.android.domain.model.College import com.eatssu.android.domain.model.Department import com.eatssu.android.domain.repository.UserRepository import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase +import com.eatssu.android.domain.usecase.user.GetUserEmailUseCase import com.eatssu.android.domain.usecase.user.NicknameValidationResult import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase import com.eatssu.android.domain.usecase.user.SetUserNicknameUseCase @@ -30,10 +32,12 @@ import javax.inject.Inject class UserInfoViewModel @Inject constructor( private val setUserNicknameUseCase: SetUserNicknameUseCase, private val getUserCollegeDepartmentUseCase: GetUserCollegeDepartmentUseCase, + private val getUserEmailUseCase: GetUserEmailUseCase, private val setUserCollegeDepartmentUseCase: SetUserCollegeDepartmentUseCase, private val validateNicknameServerUseCase: ValidateNicknameServerUseCase, private val validateNicknameLocalUseCase: ValidateNicknameLocalUseCase, private val userRepository: UserRepository, + private val analyticsIdentityManager: AnalyticsIdentityManager, ) : ViewModel() { companion object { @@ -296,6 +300,12 @@ class UserInfoViewModel @Inject constructor( else -> UiText.StringResource(R.string.toast_no_changes) } + syncAnalyticsIdentity( + nickname = data.nickname, + college = data.selectedCollege ?: data.originalCollege, + department = data.selectedDepartment ?: data.originalDepartment, + ) + _uiEvent.emit(UiEvent.ShowToast(message, ToastType.INFO)) _uiState.update { UiState.Success( @@ -306,6 +316,22 @@ class UserInfoViewModel @Inject constructor( } } } + + private suspend fun syncAnalyticsIdentity( + nickname: String, + college: College?, + department: Department?, + ) { + val email = getUserEmailUseCase() + if (email.isBlank()) return + + analyticsIdentityManager.identifyUser( + email = email, + nickname = nickname, + college = college, + department = department, + ) + } } // 화면에 표시할 실제 데이터 diff --git a/app/src/main/java/com/eatssu/android/presentation/util/AnalyticsUtil.kt b/app/src/main/java/com/eatssu/android/presentation/util/AnalyticsUtil.kt index e4ee00403..c063f1901 100644 --- a/app/src/main/java/com/eatssu/android/presentation/util/AnalyticsUtil.kt +++ b/app/src/main/java/com/eatssu/android/presentation/util/AnalyticsUtil.kt @@ -2,12 +2,17 @@ package com.eatssu.android.presentation.util import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import com.eatssu.common.EventLogger +import com.eatssu.android.analytics.LocalAnalyticsTracker +import com.eatssu.common.analytics.ScreenViewEvent import com.eatssu.common.enums.ScreenId @Composable fun TrackScreenViewEvent( - screenId: ScreenId -) = LaunchedEffect(Unit) { - EventLogger.screenView(screenId) + screenId: ScreenId, +) { + val analyticsTracker = LocalAnalyticsTracker.current + + LaunchedEffect(analyticsTracker, screenId) { + analyticsTracker.track(ScreenViewEvent(screenId)) + } } diff --git a/app/src/main/java/com/eatssu/android/presentation/widget/MealWidgetReceiver.kt b/app/src/main/java/com/eatssu/android/presentation/widget/MealWidgetReceiver.kt index 492486a66..a7d513c74 100644 --- a/app/src/main/java/com/eatssu/android/presentation/widget/MealWidgetReceiver.kt +++ b/app/src/main/java/com/eatssu/android/presentation/widget/MealWidgetReceiver.kt @@ -4,14 +4,19 @@ import android.content.Context import androidx.glance.appwidget.GlanceAppWidget import androidx.glance.appwidget.GlanceAppWidgetReceiver import com.eatssu.android.presentation.widget.ui.MealWidget -import com.eatssu.common.EventLogger +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.WidgetAnalyticsEvent import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.runBlocking import timber.log.Timber import java.io.File +import javax.inject.Inject @AndroidEntryPoint class MealWidgetReceiver : GlanceAppWidgetReceiver() { + @Inject + lateinit var analyticsTracker: AnalyticsTracker + override val glanceAppWidget: GlanceAppWidget get() = MealWidget() @@ -35,11 +40,11 @@ class MealWidgetReceiver : GlanceAppWidgetReceiver() { Timber.d("Deleted DataStore file for widget $appWidgetId") } - EventLogger.removeWidget() + analyticsTracker.track(WidgetAnalyticsEvent.Removed()) } } catch (e: Exception) { Timber.e("Failed to cleanup DataStore for widget $appWidgetId: ${e.message}") } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/eatssu/android/presentation/widget/ui/WidgetSettingActivity.kt b/app/src/main/java/com/eatssu/android/presentation/widget/ui/WidgetSettingActivity.kt index 2cb2d5fad..4ef999897 100644 --- a/app/src/main/java/com/eatssu/android/presentation/widget/ui/WidgetSettingActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/widget/ui/WidgetSettingActivity.kt @@ -15,8 +15,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.glance.GlanceId import androidx.glance.appwidget.GlanceAppWidgetManager import androidx.lifecycle.lifecycleScope +import com.eatssu.android.analytics.ProvideAnalyticsTracker import com.eatssu.android.domain.usecase.widget.SaveRestaurantByFileKeyUseCase import com.eatssu.android.presentation.widget.MealWorker +import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.common.enums.Restaurant import com.eatssu.design_system.theme.EatssuTheme import dagger.hilt.android.AndroidEntryPoint @@ -30,74 +32,79 @@ class WidgetSettingActivity : ComponentActivity() { @Inject lateinit var saveRestaurantByFileKeyUseCase: SaveRestaurantByFileKeyUseCase + @Inject + lateinit var analyticsTracker: AnalyticsTracker + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { - EatssuTheme { + ProvideAnalyticsTracker(analyticsTracker) { + EatssuTheme { - val restaurantOptions = Restaurant.getVariableRestaurantList().map { - getString(it.displayNameResId) - } // 변동 식당만 불러옵니다. 하드코딩 x + val restaurantOptions = Restaurant.getVariableRestaurantList().map { + getString(it.displayNameResId) + } // 변동 식당만 불러옵니다. 하드코딩 x - var selectedRestaurant by rememberSaveable { mutableStateOf(restaurantOptions[0]) } + var selectedRestaurant by rememberSaveable { mutableStateOf(restaurantOptions[0]) } - val appWidgetId = intent?.getIntExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - AppWidgetManager.INVALID_APPWIDGET_ID - ) - var glanceId by remember { mutableStateOf(null) } - val context = LocalContext.current - LaunchedEffect(appWidgetId) { - glanceId = - if (appWidgetId != null && appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { - GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) - } else { - null + val appWidgetId = intent?.getIntExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + AppWidgetManager.INVALID_APPWIDGET_ID + ) + var glanceId by remember { mutableStateOf(null) } + val context = LocalContext.current + LaunchedEffect(appWidgetId) { + glanceId = + if (appWidgetId != null && appWidgetId != AppWidgetManager.INVALID_APPWIDGET_ID) { + GlanceAppWidgetManager(context).getGlanceIdBy(appWidgetId) + } else { + null + } } - } - WidgetSettingScreen( - restaurantOptionList = restaurantOptions, - selectedRestaurant = selectedRestaurant, - onSelectRestaurant = { displayName -> - selectedRestaurant = displayName - }, - onConfirm = { selectedRestaurantValue -> - if (glanceId == null) { - finish() - return@WidgetSettingScreen - } + WidgetSettingScreen( + restaurantOptionList = restaurantOptions, + selectedRestaurant = selectedRestaurant, + onSelectRestaurant = { displayName -> + selectedRestaurant = displayName + }, + onConfirm = { selectedRestaurantValue -> + if (glanceId == null) { + finish() + return@WidgetSettingScreen + } - lifecycleScope.launch { + lifecycleScope.launch { - saveRestaurantByFileKeyUseCase( - "appWidget-${appWidgetId}", - selectedRestaurantValue - ) + saveRestaurantByFileKeyUseCase( + "appWidget-${appWidgetId}", + selectedRestaurantValue + ) - // 위젯 업데이트 - glanceId?.let { - MealWidget().update(this@WidgetSettingActivity, it) - } + // 위젯 업데이트 + glanceId?.let { + MealWidget().update(this@WidgetSettingActivity, it) + } - // MealWorker 실행 - MealWorker.enqueue(this@WidgetSettingActivity) - - Timber.d("선택하기 버튼으로 저장: $selectedRestaurantValue for glanceId: $glanceId") - } + // MealWorker 실행 + MealWorker.enqueue(this@WidgetSettingActivity) - // 결과 설정 - val resultIntent = Intent().apply { - putExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - appWidgetId ?: AppWidgetManager.INVALID_APPWIDGET_ID - ) - } - setResult(RESULT_OK, resultIntent) - finish() - }, - onBack = { finish() } - ) + Timber.d("선택하기 버튼으로 저장: $selectedRestaurantValue for glanceId: $glanceId") + } + + // 결과 설정 + val resultIntent = Intent().apply { + putExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + appWidgetId ?: AppWidgetManager.INVALID_APPWIDGET_ID + ) + } + setResult(RESULT_OK, resultIntent) + finish() + }, + onBack = { finish() } + ) + } } } } diff --git a/app/src/main/java/com/eatssu/android/presentation/widget/ui/WidgetSettingScreen.kt b/app/src/main/java/com/eatssu/android/presentation/widget/ui/WidgetSettingScreen.kt index afeafc461..10681885b 100644 --- a/app/src/main/java/com/eatssu/android/presentation/widget/ui/WidgetSettingScreen.kt +++ b/app/src/main/java/com/eatssu/android/presentation/widget/ui/WidgetSettingScreen.kt @@ -16,8 +16,9 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.eatssu.android.R +import com.eatssu.android.analytics.LocalAnalyticsTracker +import com.eatssu.common.analytics.WidgetAnalyticsEvent import com.eatssu.android.presentation.util.asString -import com.eatssu.common.EventLogger import com.eatssu.common.enums.Restaurant import com.eatssu.design_system.component.EatSsuButton import com.eatssu.design_system.component.EatSsuRadioButtonGroup @@ -33,6 +34,8 @@ fun WidgetSettingScreen( onConfirm: (Restaurant) -> Unit = {}, onBack: () -> Unit = {} // 뒤로가기 동작을 위한 람다 추가 ) { + val analyticsTracker = LocalAnalyticsTracker.current + // onClick 람다에서 LocalContext 접근이 불가하므로 Composable 레벨에서 미리 매핑 생성 val restaurantDisplayNameMap = Restaurant.getVariableRestaurantList() .associateBy { it.toUiText().asString() } @@ -74,7 +77,7 @@ fun WidgetSettingScreen( ?: Restaurant.HAKSIK onConfirm(selectedRestaurantEnum) - EventLogger.addWidget(selectedRestaurantEnum) + analyticsTracker.track(WidgetAnalyticsEvent.Added(selectedRestaurantEnum)) } ) } @@ -96,4 +99,4 @@ fun PreviewWidgetSettingScreen() { onBack = {} ) } -} \ No newline at end of file +} diff --git a/app/src/test/java/com/eatssu/android/analytics/AnalyticsIdentityManagerTest.kt b/app/src/test/java/com/eatssu/android/analytics/AnalyticsIdentityManagerTest.kt new file mode 100644 index 000000000..5db0c1108 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/analytics/AnalyticsIdentityManagerTest.kt @@ -0,0 +1,88 @@ +package com.eatssu.android.analytics + +import com.eatssu.android.domain.model.College +import com.eatssu.android.domain.model.Department +import com.eatssu.common.analytics.AnalyticsEvent +import com.eatssu.common.analytics.AnalyticsIdentity +import com.eatssu.common.analytics.AnalyticsTracker +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class AnalyticsIdentityManagerTest { + + private val tracker = FakeAnalyticsTracker() + private val manager = AnalyticsIdentityManager(tracker) + + @Test + fun `blank email is ignored`() { + manager.identifyUser(email = " ") + + assertTrue(tracker.identities.isEmpty()) + } + + @Test + fun `identifyUser trims values and removes placeholder college department`() { + manager.identifyUser( + email = " test@soongsil.ac.kr ", + nickname = " eatssu ", + college = College(collegeId = -1, collegeName = "단과대"), + department = Department(departmentId = -1, departmentName = "학과"), + ) + + val identity = tracker.identities.single() + assertEquals("test@soongsil.ac.kr", identity.distinctId) + assertEquals("test@soongsil.ac.kr", identity.email) + assertEquals("eatssu", identity.nickname) + assertNull(identity.collegeId) + assertNull(identity.collegeName) + assertNull(identity.departmentId) + assertNull(identity.departmentName) + } + + @Test + fun `identifyUser keeps valid college and department`() { + manager.identifyUser( + email = "test@soongsil.ac.kr", + college = College(collegeId = 1, collegeName = "IT대"), + department = Department(departmentId = 2, departmentName = "소프트웨어학부"), + ) + + assertEquals( + AnalyticsIdentity( + distinctId = "test@soongsil.ac.kr", + email = "test@soongsil.ac.kr", + collegeId = 1, + collegeName = "IT대", + departmentId = 2, + departmentName = "소프트웨어학부", + ), + tracker.identities.single(), + ) + } + + @Test + fun `resetIdentity delegates to tracker`() { + manager.resetIdentity() + + assertTrue(tracker.resetCalled) + } + + private class FakeAnalyticsTracker : AnalyticsTracker { + override val id: String = "fake" + + val identities = mutableListOf() + var resetCalled: Boolean = false + + override fun track(event: AnalyticsEvent) = Unit + + override fun identify(identity: AnalyticsIdentity) { + identities += identity + } + + override fun resetIdentity() { + resetCalled = true + } + } +} diff --git a/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt b/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt new file mode 100644 index 000000000..b3ee49a27 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt @@ -0,0 +1,103 @@ +package com.eatssu.android.analytics + +import com.eatssu.common.analytics.AnalyticsEvent +import com.eatssu.common.analytics.AnalyticsIdentity +import com.eatssu.common.analytics.MapAnalyticsEvent +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.ReviewAnalyticsEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DefaultAnalyticsTrackerTest { + + @Test + fun `track dispatches typed event to all trackers`() { + val firebaseTracker = FakeAnalyticsTracker(id = "firebase") + val postHogTracker = FakeAnalyticsTracker(id = "posthog") + val analyticsTracker = DefaultAnalyticsTracker(setOf(firebaseTracker, postHogTracker)) + val event = ReviewAnalyticsEvent.Completed(rating = 5L, likes = 2L, photoAttached = true) + + analyticsTracker.track(event) + + assertEquals(listOf(event), firebaseTracker.events) + assertEquals(listOf(event), postHogTracker.events) + } + + @Test + fun `duplicate tracker ids are de duplicated`() { + val first = FakeAnalyticsTracker(id = "duplicate") + val second = FakeAnalyticsTracker(id = "duplicate") + val analyticsTracker = DefaultAnalyticsTracker(setOf(first, second)) + + analyticsTracker.track(MapAnalyticsEvent.AllClicked) + + assertEquals(1, first.events.size + second.events.size) + } + + @Test + fun `identify propagates typed identity to all trackers`() { + val firebaseTracker = FakeAnalyticsTracker(id = "firebase") + val postHogTracker = FakeAnalyticsTracker(id = "posthog") + val analyticsTracker = DefaultAnalyticsTracker(setOf(firebaseTracker, postHogTracker)) + val identity = AnalyticsIdentity( + distinctId = "test@soongsil.ac.kr", + email = "test@soongsil.ac.kr", + nickname = "eatssu", + ) + + analyticsTracker.identify(identity) + + assertEquals(listOf(identity), firebaseTracker.identities) + assertEquals(listOf(identity), postHogTracker.identities) + } + + @Test + fun `track isolates tracker failures`() { + val failingTracker = FakeAnalyticsTracker(id = "firebase", failOnTrack = true) + val healthyTracker = FakeAnalyticsTracker(id = "posthog") + val analyticsTracker = DefaultAnalyticsTracker(setOf(failingTracker, healthyTracker)) + val event = MapAnalyticsEvent.AllClicked + + analyticsTracker.track(event) + + assertTrue(failingTracker.trackAttempted) + assertEquals(listOf(event), healthyTracker.events) + } + + @Test + fun `resetIdentity resets every tracker`() { + val firebaseTracker = FakeAnalyticsTracker(id = "firebase") + val postHogTracker = FakeAnalyticsTracker(id = "posthog") + val analyticsTracker = DefaultAnalyticsTracker(setOf(firebaseTracker, postHogTracker)) + + analyticsTracker.resetIdentity() + + assertTrue(firebaseTracker.resetCalled) + assertTrue(postHogTracker.resetCalled) + } + + private class FakeAnalyticsTracker( + override val id: String, + private val failOnTrack: Boolean = false, + ) : AnalyticsTracker { + val events = mutableListOf() + val identities = mutableListOf() + var resetCalled: Boolean = false + var trackAttempted: Boolean = false + + override fun track(event: AnalyticsEvent) { + trackAttempted = true + if (failOnTrack) error("track failed") + events += event + } + + override fun identify(identity: AnalyticsIdentity) { + identities += identity + } + + override fun resetIdentity() { + resetCalled = true + } + } +} diff --git a/app/src/test/java/com/eatssu/android/analytics/FirebaseAnalyticsTrackerTest.kt b/app/src/test/java/com/eatssu/android/analytics/FirebaseAnalyticsTrackerTest.kt new file mode 100644 index 000000000..460638f93 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/analytics/FirebaseAnalyticsTrackerTest.kt @@ -0,0 +1,46 @@ +package com.eatssu.android.analytics + +import com.eatssu.common.analytics.ReviewAnalyticsEvent +import com.eatssu.common.analytics.ScreenViewEvent +import com.eatssu.common.enums.ScreenId +import org.junit.Assert.assertEquals +import org.junit.Test + +class FirebaseAnalyticsTrackerTest { + + @Test + fun `screen view payload uses firebase screen keys`() { + val payload = ScreenViewEvent( + screenId = ScreenId.HOME_MAIN, + screenClass = "MainActivity", + ).toPayload() + + assertEquals("screen_view", payload.eventName) + assertEquals( + mapOf( + "screen_name" to ScreenId.HOME_MAIN.value, + "screen_class" to "MainActivity", + ), + payload.properties, + ) + } + + @Test + fun `review completion payload keeps firebase compatible boolean value`() { + val payload = ReviewAnalyticsEvent.Completed( + rating = 5L, + likes = 2L, + photoAttached = true, + ).toPayload() + + assertEquals("complete_review_v2", payload.eventName) + assertEquals( + mapOf( + "rating" to 5L, + "likes" to 2L, + "photo_attached" to true, + ), + payload.properties, + ) + } +} diff --git a/app/src/test/java/com/eatssu/android/analytics/PostHogAnalyticsTrackerTest.kt b/app/src/test/java/com/eatssu/android/analytics/PostHogAnalyticsTrackerTest.kt new file mode 100644 index 000000000..973216223 --- /dev/null +++ b/app/src/test/java/com/eatssu/android/analytics/PostHogAnalyticsTrackerTest.kt @@ -0,0 +1,60 @@ +package com.eatssu.android.analytics + +import com.eatssu.common.analytics.AnalyticsIdentity +import com.eatssu.common.analytics.AppAnalyticsEvent +import com.eatssu.common.analytics.WidgetAnalyticsEvent +import com.eatssu.common.enums.LaunchPath +import com.eatssu.common.enums.Restaurant +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class PostHogAnalyticsTrackerTest { + + @Test + fun `launch payload keeps posthog compatible schema`() { + val payload = AppAnalyticsEvent.Launch(LaunchPath.WIDGET).toPayload() + + assertEquals("app_launch", payload.eventName) + assertEquals(mapOf("launch_path" to "widget"), payload.properties) + } + + @Test + fun `widget removal without restaurant omits properties`() { + val payload = WidgetAnalyticsEvent.Removed().toPayload() + + assertEquals("remove_widget", payload.eventName) + assertTrue(payload.properties.isEmpty()) + } + + @Test + fun `identity properties exclude null values`() { + val properties = AnalyticsIdentity( + distinctId = "test@soongsil.ac.kr", + email = "test@soongsil.ac.kr", + nickname = "eatssu", + collegeId = 1, + collegeName = "IT대", + departmentId = null, + departmentName = null, + ).toProperties() + + assertEquals( + mapOf( + "email" to "test@soongsil.ac.kr", + "nickname" to "eatssu", + "college_id" to 1, + "college_name" to "IT대", + ), + properties, + ) + } + + @Test + fun `widget addition payload keeps restaurant key`() { + val payload = WidgetAnalyticsEvent.Added(Restaurant.HAKSIK).toPayload() + + assertEquals("add_widget", payload.eventName) + assertEquals(mapOf("restaurants" to "haksik"), payload.properties) + } +} diff --git a/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt index 89def3b02..fc704b3be 100644 --- a/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/domain/usecase/auth/AuthDelegatingUseCasesBehaviorSpec.kt @@ -9,6 +9,7 @@ import com.eatssu.android.domain.repository.OauthRepository import com.eatssu.android.domain.repository.UserRepository import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.android.test.sampleToken +import com.eatssu.common.analytics.AnalyticsTracker import com.eatssu.common.enums.DeviceType import io.kotest.matchers.shouldBe import io.mockk.Runs @@ -108,7 +109,8 @@ class AuthDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({ val accountDataStore = mockk() val tokenStore = mockk() val settingDataStore = mockk() - val useCase = LogoutUseCase(accountDataStore, tokenStore, settingDataStore) + val analyticsTracker = mockk(relaxed = true) + val useCase = LogoutUseCase(accountDataStore, tokenStore, settingDataStore, analyticsTracker) coJustRun { accountDataStore.clear() } every { tokenStore.clear() } just Runs @@ -123,6 +125,7 @@ class AuthDelegatingUseCasesBehaviorSpec : AppBehaviorSpec({ tokenStore.clear() settingDataStore.clear() } + io.mockk.verify(exactly = 1) { analyticsTracker.resetIdentity() } } } } diff --git a/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt index 50905f665..9744c84a2 100644 --- a/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt @@ -2,11 +2,13 @@ package com.eatssu.android.presentation import app.cash.turbine.test import com.eatssu.android.R +import com.eatssu.android.analytics.AnalyticsIdentityManager import com.eatssu.android.domain.model.College import com.eatssu.android.domain.model.Department import com.eatssu.android.domain.repository.UserRepository import com.eatssu.android.domain.usecase.auth.LogoutUseCase import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase +import com.eatssu.android.domain.usecase.user.GetUserEmailUseCase import com.eatssu.android.domain.usecase.user.GetUserNickNameUseCase import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase import com.eatssu.android.test.AppBehaviorSpec @@ -34,6 +36,8 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ val setUserCollegeDepartmentUseCase = mockk() val userRepository = mockk() val getUserCollegeDepartmentUseCase = mockk() + val getUserEmailUseCase = mockk() + val analyticsIdentityManager = mockk(relaxed = true) val college = College(collegeId = 1, collegeName = "IT") val department = Department(departmentId = 11, departmentName = "컴퓨터학부") @@ -45,6 +49,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { logoutUseCase() } returns Unit coEvery { getUserNickNameUseCase() } returns "eatssu" + coEvery { getUserEmailUseCase() } returns "test@soongsil.ac.kr" coEvery { getUserCollegeDepartmentUseCase() } returns userInfo coEvery { userRepository.getUserCollegeDepartment() } returns (college to department) coEvery { setUserCollegeDepartmentUseCase(college, department) } returns Unit @@ -56,6 +61,8 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, userRepository = userRepository, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + getUserEmailUseCase = getUserEmailUseCase, + analyticsIdentityManager = analyticsIdentityManager, ) then("부서명이 반영된 DepartmentState로 전이된다") { @@ -87,6 +94,8 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, userRepository = userRepository, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + getUserEmailUseCase = getUserEmailUseCase, + analyticsIdentityManager = analyticsIdentityManager, ) advanceUntilIdle() @@ -126,6 +135,8 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, userRepository = userRepository, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + getUserEmailUseCase = getUserEmailUseCase, + analyticsIdentityManager = analyticsIdentityManager, ) viewModel.uiEvent.test { @@ -153,6 +164,8 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, userRepository = userRepository, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + getUserEmailUseCase = getUserEmailUseCase, + analyticsIdentityManager = analyticsIdentityManager, ) viewModel.uiEvent.test { @@ -171,6 +184,8 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, userRepository = userRepository, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + getUserEmailUseCase = getUserEmailUseCase, + analyticsIdentityManager = analyticsIdentityManager, ) then("로그아웃 유즈케이스 호출 후 성공 토스트와 LoggedOut 상태를 반영한다") { diff --git a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt index 1f1fd33b0..ce370d3b8 100644 --- a/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewViewModelBehaviorSpec.kt @@ -13,7 +13,8 @@ import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.android.test.expectNavigateBack import com.eatssu.android.test.expectToast import com.eatssu.android.test.successDataAs -import com.eatssu.common.EventLogger +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.ReviewAnalyticsEvent import com.eatssu.common.UiState import com.eatssu.common.enums.MenuType import com.eatssu.common.enums.ToastType @@ -27,6 +28,7 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest @@ -41,9 +43,10 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ val writeReviewUseCase = mockk() val getImageUrlUseCase = mockk() val getValidMenusOfMealUseCase = mockk() + val analyticsTracker = mockk(relaxed = true) `when`("고정 메뉴 타입을 로드하면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) then("단일 메뉴 Editing 상태를 만든다") { runTest { @@ -64,7 +67,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("가변 메뉴 타입을 로드하면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) val menus = listOf(MenuMini(10L, "A"), MenuMini(11L, "B")) coEvery { getValidMenusOfMealUseCase(999L) } returns menus @@ -85,7 +88,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("rating이 0인 상태에서 submit하면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) then("요청하지 않는다") { runTest { @@ -103,7 +106,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("Editing 상태가 아닐 때 submit하면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) then("아무 동작도 수행하지 않는다") { runTest { @@ -119,7 +122,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("좋아요 메뉴를 같은 id로 두 번 토글하면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) then("likedMenuIds가 추가됐다가 다시 제거된다") { runTest { @@ -136,7 +139,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("이미지 없이 리뷰 작성이 성공하면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) coEvery { writeReviewUseCase( menuType = MenuType.FIXED, @@ -147,8 +150,6 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ likeMenuIdList = any(), ) } returns true - mockkObject(EventLogger) - every { EventLogger.completeReview(any(), any(), any()) } just Runs then("성공 토스트와 NavigateBack 이벤트를 보낸다") { runTest { @@ -163,6 +164,15 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ expectToast(R.string.toast_review_write_success, ToastType.SUCCESS) expectNavigateBack() + verify { + analyticsTracker.track( + ReviewAnalyticsEvent.Completed( + rating = 5, + likes = 0, + photoAttached = false, + ), + ) + } cancelAndIgnoreRemainingEvents() } } @@ -170,7 +180,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("이미지 업로드 성공 후 리뷰 작성이 성공하면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) val context = mockk() val resolver = mockk() val uri = mockk() @@ -187,8 +197,6 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { writeReviewUseCase(MenuType.FIXED, 1L, 4, "", "https://img", any()) } returns true - mockkObject(EventLogger) - every { EventLogger.completeReview(any(), any(), any()) } just Runs then("이미지 업로드 성공 토스트 후 리뷰 성공 토스트와 뒤로가기를 보낸다") { runTest { @@ -205,6 +213,15 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ expectToast(R.string.toast_image_upload_success, ToastType.SUCCESS) expectToast(R.string.toast_review_write_success, ToastType.SUCCESS) expectNavigateBack() + verify { + analyticsTracker.track( + ReviewAnalyticsEvent.Completed( + rating = 4, + likes = 0, + photoAttached = true, + ), + ) + } cancelAndIgnoreRemainingEvents() } } @@ -212,7 +229,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("이미지 업로드 URL이 null이면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) val context = mockk() val resolver = mockk() val uri = mockk() @@ -248,7 +265,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("이미지 압축이 실패하면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) val context = mockk() val resolver = mockk() val uri = mockk() @@ -282,7 +299,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("이미지 업로드 과정에서 예외가 발생하면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) val context = mockk() val resolver = mockk() val uri = mockk() @@ -309,7 +326,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ } `when`("리뷰 작성 API가 실패하면") { - val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase) + val viewModel = WriteReviewViewModel(writeReviewUseCase, getImageUrlUseCase, getValidMenusOfMealUseCase, analyticsTracker) coEvery { writeReviewUseCase(MenuType.FIXED, 1L, 3, "", null, any()) } returns false then("Editing으로 롤백하고 실패 토스트를 보낸다") { diff --git a/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt index 4707d436e..9181c6562 100644 --- a/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/login/LoginViewModelBehaviorSpec.kt @@ -2,6 +2,7 @@ package com.eatssu.android.presentation.login import app.cash.turbine.test import com.eatssu.android.R +import com.eatssu.android.analytics.AnalyticsIdentityManager import com.eatssu.android.domain.usecase.auth.LoginUseCase import com.eatssu.android.domain.usecase.auth.SetAccessTokenUseCase import com.eatssu.android.domain.usecase.auth.SetRefreshTokenUseCase @@ -35,6 +36,7 @@ class LoginViewModelBehaviorSpec : AppBehaviorSpec({ val setAccessTokenUseCase = mockk() val setRefreshTokenUseCase = mockk() val setUserEmailUseCase = mockk() + val analyticsIdentityManager = mockk(relaxed = true) every { setAccessTokenUseCase(any()) } just Runs every { setRefreshTokenUseCase(any()) } just Runs @@ -46,6 +48,7 @@ class LoginViewModelBehaviorSpec : AppBehaviorSpec({ setAccessTokenUseCase = setAccessTokenUseCase, setRefreshTokenUseCase = setRefreshTokenUseCase, setUserEmailUseCase = setUserEmailUseCase, + analyticsIdentityManager = analyticsIdentityManager, ) coEvery { loginUseCase("a@b.com", "pid", DeviceType.ANDROID) } returns null @@ -69,6 +72,7 @@ class LoginViewModelBehaviorSpec : AppBehaviorSpec({ setAccessTokenUseCase = setAccessTokenUseCase, setRefreshTokenUseCase = setRefreshTokenUseCase, setUserEmailUseCase = setUserEmailUseCase, + analyticsIdentityManager = analyticsIdentityManager, ) val token = sampleToken("acc", "ref") coEvery { loginUseCase("a@b.com", "pid", DeviceType.ANDROID) } returns token @@ -81,6 +85,7 @@ class LoginViewModelBehaviorSpec : AppBehaviorSpec({ verify { setAccessTokenUseCase("acc") } verify { setRefreshTokenUseCase("ref") } coVerify { setUserEmailUseCase("a@b.com") } + verify { analyticsIdentityManager.identifyUser(email = "a@b.com") } viewModel.uiState.value shouldBe UiState.Success(LoginState.LoginSuccess) } } @@ -94,6 +99,7 @@ class LoginViewModelBehaviorSpec : AppBehaviorSpec({ setAccessTokenUseCase = mockk(relaxed = true), setRefreshTokenUseCase = mockk(relaxed = true), setUserEmailUseCase = mockk(relaxed = true), + analyticsIdentityManager = mockk(relaxed = true), ) `when`("setLoadingState를 호출하면") { diff --git a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt index f6a66dbc8..a1aacf867 100644 --- a/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/map/MapViewModelBehaviorSpec.kt @@ -11,7 +11,8 @@ import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.android.test.samplePartnership import com.eatssu.android.test.samplePartnershipRestaurant import com.eatssu.android.test.sampleUserInfo -import com.eatssu.common.EventLogger +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.MapAnalyticsEvent import com.eatssu.common.UiState import com.eatssu.common.enums.StoreType import io.kotest.assertions.nondeterministic.eventually @@ -37,10 +38,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ val partnershipRepository = mockk() val getPartnershipDetailUseCase = mockk() val getUserCollegeDepartmentUseCase = mockk() - - mockkObject(EventLogger) - every { EventLogger.clickMap() } just Runs - every { EventLogger.clickMapMine(any(), any()) } just Runs + val analyticsTracker = mockk(relaxed = true) `when`("학과 정보가 없어서 초기 필터가 전체일 때") { val allPartnerships = listOf(samplePartnership(storeName = "All Cafe")) @@ -58,6 +56,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ partnershipRepository = partnershipRepository, getPartnershipDetailUseCase = getPartnershipDetailUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, ) then("All 필터로 시작하고 전체 제휴 목록을 로드한다") { @@ -87,6 +86,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ partnershipRepository = partnershipRepository, getPartnershipDetailUseCase = getPartnershipDetailUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, ) then("RequiresDepartment 결과를 상태에 반영하고 Mine 데이터를 로드하지 않는다") { @@ -124,6 +124,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ partnershipRepository = partnershipRepository, getPartnershipDetailUseCase = getPartnershipDetailUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, ) then("필터에 맞는 목록을 로드하고 이벤트 로깅을 수행한다") { @@ -140,7 +141,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ allState.data.selectedFilter shouldBe FilterType.All allState.data.partnerships shouldBe allPartnerships } - verify(atLeast = 1) { EventLogger.clickMap() } + verify(atLeast = 1) { analyticsTracker.track(MapAnalyticsEvent.AllClicked) } viewModel.setFilter(FilterType.Mine) eventually(2.seconds) { @@ -148,7 +149,11 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ mineState.data.selectedFilter shouldBe FilterType.Mine mineState.data.partnerships shouldBe minePartnerships } - verify(atLeast = 1) { EventLogger.clickMapMine(1L, 11L) } + verify(atLeast = 1) { + analyticsTracker.track( + MapAnalyticsEvent.MineClicked(college = 1L, major = 11L), + ) + } } } } @@ -207,6 +212,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ partnershipRepository = partnershipRepository, getPartnershipDetailUseCase = getPartnershipDetailUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, ) then("대표 제휴와 표시용 리스트/장소 타입을 상태에 반영한다") { @@ -244,6 +250,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ partnershipRepository = partnershipRepository, getPartnershipDetailUseCase = getPartnershipDetailUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, ) then("상태를 변경하지 않고 반환한다") { @@ -269,6 +276,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ partnershipRepository = partnershipRepository, getPartnershipDetailUseCase = getPartnershipDetailUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, ) then("선택 상태를 갱신하지 않는다") { @@ -321,6 +329,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ partnershipRepository = partnershipRepository, getPartnershipDetailUseCase = getPartnershipDetailUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, ) then("선택 상태를 갱신하지 않는다") { @@ -357,6 +366,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ partnershipRepository = partnershipRepository, getPartnershipDetailUseCase = getPartnershipDetailUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, ) then("StoreType.CAFE로 변환한다") { @@ -393,6 +403,7 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ partnershipRepository = partnershipRepository, getPartnershipDetailUseCase = getPartnershipDetailUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + analyticsTracker = analyticsTracker, ) then("StoreType.RESTAURANT로 변환한다") { diff --git a/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt index e8a9ca08a..844407056 100644 --- a/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/mypage/userinfo/UserInfoViewModelBehaviorSpec.kt @@ -2,10 +2,12 @@ package com.eatssu.android.presentation.mypage.userinfo import app.cash.turbine.test import com.eatssu.android.R +import com.eatssu.android.analytics.AnalyticsIdentityManager import com.eatssu.android.domain.model.College import com.eatssu.android.domain.model.Department import com.eatssu.android.domain.repository.UserRepository import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase +import com.eatssu.android.domain.usecase.user.GetUserEmailUseCase import com.eatssu.android.domain.usecase.user.NicknameValidationResult import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase import com.eatssu.android.domain.usecase.user.SetUserNicknameUseCase @@ -32,6 +34,26 @@ import kotlin.time.Duration.Companion.seconds @OptIn(ExperimentalCoroutinesApi::class) class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ + fun buildViewModel( + setUserNicknameUseCase: SetUserNicknameUseCase, + getUserCollegeDepartmentUseCase: GetUserCollegeDepartmentUseCase, + setUserCollegeDepartmentUseCase: SetUserCollegeDepartmentUseCase, + validateNicknameServerUseCase: ValidateNicknameServerUseCase, + validateNicknameLocalUseCase: ValidateNicknameLocalUseCase, + userRepository: UserRepository, + getUserEmailUseCase: GetUserEmailUseCase = mockk(relaxed = true), + analyticsIdentityManager: AnalyticsIdentityManager = mockk(relaxed = true), + ) = UserInfoViewModel( + setUserNicknameUseCase = setUserNicknameUseCase, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + getUserEmailUseCase = getUserEmailUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + validateNicknameServerUseCase = validateNicknameServerUseCase, + validateNicknameLocalUseCase = validateNicknameLocalUseCase, + userRepository = userRepository, + analyticsIdentityManager = analyticsIdentityManager, + ) + given("유저 정보 수정 화면") { val baseCollege = College(collegeId = 1, collegeName = "IT") val baseDepartment = Department(departmentId = 11, departmentName = "컴퓨터학부") @@ -57,7 +79,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -101,7 +123,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ validateNicknameLocalUseCase("x", UserInfoViewModel.MIN_NICKNAME_LENGTH, UserInfoViewModel.MAX_NICKNAME_LENGTH) } returns NicknameValidationResult.Invalid(UiText.StringResource(R.string.nickname_error_length)) - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -147,7 +169,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid coEvery { validateNicknameServerUseCase("newNick") } returns Result.failure(IllegalArgumentException("dup")) - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -193,7 +215,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -242,7 +264,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid coEvery { setUserNicknameUseCase("newNick") } returns Result.failure(IllegalStateException("fail")) - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -299,7 +321,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { userRepository.setUserDepartment(otherDepartment.departmentId) } returns true coEvery { setUserCollegeDepartmentUseCase(otherCollege, otherDepartment) } returns Unit - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -358,7 +380,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid coEvery { validateNicknameServerUseCase("newNick") } returns Result.success(Unit) - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -405,7 +427,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid coEvery { validateNicknameServerUseCase("newNick") } returns Result.failure(IllegalStateException()) - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -452,7 +474,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { validateNicknameServerUseCase("newNick") } returns Result.success(Unit) coEvery { setUserNicknameUseCase("newNick") } returns Result.success(Unit) - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -501,7 +523,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { userRepository.setUserDepartment(otherDepartment.departmentId) } returns true coEvery { setUserCollegeDepartmentUseCase(otherCollege, otherDepartment) } returns Unit - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -552,7 +574,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ coEvery { userRepository.getTotalDepartments(baseCollege.collegeId) } returns listOf(baseDepartment) every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, @@ -607,7 +629,7 @@ class UserInfoViewModelBehaviorSpec : AppBehaviorSpec({ every { validateNicknameLocalUseCase(any(), any(), any()) } returns NicknameValidationResult.Valid coEvery { userRepository.setUserDepartment(otherDepartment.departmentId) } returns false - val viewModel = UserInfoViewModel( + val viewModel = buildViewModel( setUserNicknameUseCase = setUserNicknameUseCase, getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index f7ebb78f9..b060a19bd 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -53,9 +53,5 @@ dependencies { androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.androidx.espresso.core) - // Firebase - implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.analytics) - implementation(libs.kotlinx.serialization.json) } diff --git a/core/common/src/main/java/com/eatssu/common/EventLogger.kt b/core/common/src/main/java/com/eatssu/common/EventLogger.kt deleted file mode 100644 index 00baeb1cd..000000000 --- a/core/common/src/main/java/com/eatssu/common/EventLogger.kt +++ /dev/null @@ -1,156 +0,0 @@ -package com.eatssu.common - -import android.os.Bundle -import com.eatssu.common.enums.EventType -import com.eatssu.common.enums.LaunchPath -import com.eatssu.common.enums.Restaurant -import com.eatssu.common.enums.ScreenId -import com.eatssu.common.enums.Time -import com.google.firebase.analytics.FirebaseAnalytics -import com.google.firebase.analytics.ParametersBuilder -import com.google.firebase.analytics.ktx.analytics -import com.google.firebase.analytics.logEvent -import com.google.firebase.ktx.Firebase - -private val firebaseAnalytics: FirebaseAnalytics by lazy { Firebase.analytics } - -object EventLogger { - - fun setUserProperties(vararg properties: Pair) { - properties.forEach { property -> - firebaseAnalytics.setUserProperty(property.first, property.second) - } - } - - fun appLaunch(launchPath: LaunchPath) { - logEvent(EventType.APP_LAUNCH) { - param("launch_path", launchPath.value) - } - } - - fun clickRestaurantInfo(restaurant: Restaurant) { - logEvent(EventType.CLICK_RESTAURANT_INFO) { - param("restaurants", restaurant.value) - } - } - - fun selectMealTime(time: Time) { - logEvent(EventType.SELECT_MEALTIME) { - param("mealtime", time.value) - } - } - - fun selectDay(day: String) { - val weekDay = when (day) { - "SUNDAY" -> "sun" - "MONDAY" -> "mon" - "TUESDAY" -> "tue" - "WEDNESDAY" -> "wed" - "THURSDAY" -> "thu" - "FRIDAY" -> "fri" - "SATURDAY" -> "sat" - else -> { - "" - } - } - logEvent(EventType.CLICK_DAY) { - param("day", weekDay) - } - } - - fun clickMenu(restaurant: Restaurant) { - logEvent(EventType.CLICK_MENU) { - param("restaurants", restaurant.value) - } - } - - fun writeReview() { - logEvent(EventType.WRITE_REVIEW_V2) - } - - fun completeReview( - rating: Long, - likes: Long, - photoAttached: Boolean, - ) { - logEvent(EventType.COMPLETE_REVIEW_V2) { - param("rating", rating) - param("likes", likes) - param("photo_attached", if (photoAttached) 1 else 0) - } - } - - fun clickMap() { - logEvent(EventType.CLICK_MAP) - } - - fun clickMapMine( - college: Long, - major: Long, - ) { - logEvent(EventType.CLICK_MAP_MINE) { - param("college", college) - param("major", major) - } - } - - fun clickPartnerRestaurant( - college: Long, - major: Long, - partnerRestaurantId: Long - ) { - logEvent(EventType.CLICK_PARTNER_RESTAURANT) { - param("college", college) - param("major", major) - param("partner_restaurant_id", partnerRestaurantId) - } - } - - fun addWidget(restaurant: Restaurant) { - logEvent(EventType.ADD_WIDGET) { - param("restaurants", restaurant.value) - } - } - - fun removeWidget() { - logEvent(EventType.REMOVE_WIDGET) - } - - //todo 파라미터 넣을지 추후 논의 - fun removeWidget(restaurant: Restaurant) { - logEvent(EventType.REMOVE_WIDGET) { - param("restaurants", restaurant.value) - } - } - - //todo change_widget - fun changeWidget(restaurant: Restaurant) { - logEvent(EventType.CHANGE_WIDGET) { - param("restaurants", restaurant.value) - } - } - - fun screenView(screenId: ScreenId, screenClass: String? = null) { - logEvent(EventType.SCREEN_VIEW) { - param(FirebaseAnalytics.Param.SCREEN_NAME, screenId.value) - - // screen_class를 설정하지 않으면 자동으로 포커스에 있는 UIViewController 또는 Activity를 기반으로 설정됨 - if (screenClass != null) - param(FirebaseAnalytics.Param.SCREEN_CLASS, screenClass) - } - } - - private fun logEvent( - eventType: EventType, - bundle: Bundle? = null - ) { - firebaseAnalytics.logEvent(eventType.value, bundle) - } - - private fun logEvent( - eventType: EventType, - block: ParametersBuilder.() -> Unit - ) { - firebaseAnalytics.logEvent(eventType.value, block) - } -} \ No newline at end of file diff --git a/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsEvent.kt b/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsEvent.kt new file mode 100644 index 000000000..75b969b12 --- /dev/null +++ b/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsEvent.kt @@ -0,0 +1,167 @@ +package com.eatssu.common.analytics + +import com.eatssu.common.enums.LaunchPath +import com.eatssu.common.enums.Restaurant +import com.eatssu.common.enums.ScreenId +import com.eatssu.common.enums.Time + +sealed interface AnalyticsEvent { + val eventName: String + val properties: Map + + fun toPayload(): AnalyticsPayload = AnalyticsPayload( + eventName = eventName, + properties = properties, + ) +} + +sealed interface AppAnalyticsEvent : AnalyticsEvent { + data class Launch( + val launchPath: LaunchPath, + ) : AppAnalyticsEvent { + override val eventName = "app_launch" + override val properties = buildMap { + put("launch_path", launchPath.value) + } + } +} + +sealed interface CafeteriaAnalyticsEvent : AnalyticsEvent { + data class RestaurantInfoClicked( + val restaurant: Restaurant, + ) : CafeteriaAnalyticsEvent { + override val eventName = "click_restaurant_info" + override val properties = buildMap { + put("restaurants", restaurant.value) + } + } + + data class MealTimeSelected( + val time: Time, + ) : CafeteriaAnalyticsEvent { + override val eventName = "select_mealtime" + override val properties = buildMap { + put("mealtime", time.value) + } + } + + data class DaySelected( + val day: String, + ) : CafeteriaAnalyticsEvent { + override val eventName = "click_day" + override val properties = buildMap { + put("day", day.toWeekdayCode()) + } + } + + data class MenuClicked( + val restaurant: Restaurant, + ) : CafeteriaAnalyticsEvent { + override val eventName = "click_menu" + override val properties = buildMap { + put("restaurants", restaurant.value) + } + } +} + +sealed interface ReviewAnalyticsEvent : AnalyticsEvent { + object WriteClicked : ReviewAnalyticsEvent { + override val eventName = "write_review_v2" + override val properties = emptyMap() + } + + data class Completed( + val rating: Long, + val likes: Long, + val photoAttached: Boolean, + ) : ReviewAnalyticsEvent { + override val eventName = "complete_review_v2" + override val properties = buildMap { + put("rating", rating) + put("likes", likes) + put("photo_attached", photoAttached) + } + } +} + +sealed interface MapAnalyticsEvent : AnalyticsEvent { + object AllClicked : MapAnalyticsEvent { + override val eventName = "click_map" + override val properties = emptyMap() + } + + data class MineClicked( + val college: Long, + val major: Long, + ) : MapAnalyticsEvent { + override val eventName = "click_map_mine" + override val properties = buildMap { + put("college", college) + put("major", major) + } + } + + data class PartnerRestaurantClicked( + val college: Long, + val major: Long, + val partnerRestaurantId: Long, + ) : MapAnalyticsEvent { + override val eventName = "click_partner_restaurant" + override val properties = buildMap { + put("college", college) + put("major", major) + put("partner_restaurant_id", partnerRestaurantId) + } + } +} + +sealed interface WidgetAnalyticsEvent : AnalyticsEvent { + data class Added( + val restaurant: Restaurant, + ) : WidgetAnalyticsEvent { + override val eventName = "add_widget" + override val properties = buildMap { + put("restaurants", restaurant.value) + } + } + + data class Removed( + val restaurant: Restaurant? = null, + ) : WidgetAnalyticsEvent { + override val eventName = "remove_widget" + override val properties = buildMap { + restaurant?.let { put("restaurants", it.value) } + } + } + + data class Changed( + val restaurant: Restaurant, + ) : WidgetAnalyticsEvent { + override val eventName = "change_widget" + override val properties = buildMap { + put("restaurants", restaurant.value) + } + } +} + +data class ScreenViewEvent( + val screenId: ScreenId, + val screenClass: String? = null, +) : AnalyticsEvent { + override val eventName = "screen_view" + override val properties = buildMap { + put("screen_name", screenId.value) + screenClass?.let { put("screen_class", it) } + } +} + +private fun String.toWeekdayCode() = when (this) { + "SUNDAY" -> "sun" + "MONDAY" -> "mon" + "TUESDAY" -> "tue" + "WEDNESDAY" -> "wed" + "THURSDAY" -> "thu" + "FRIDAY" -> "fri" + "SATURDAY" -> "sat" + else -> "" +} diff --git a/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsIdentity.kt b/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsIdentity.kt new file mode 100644 index 000000000..75925214e --- /dev/null +++ b/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsIdentity.kt @@ -0,0 +1,11 @@ +package com.eatssu.common.analytics + +data class AnalyticsIdentity( + val distinctId: String, + val email: String, + val nickname: String? = null, + val collegeId: Int? = null, + val collegeName: String? = null, + val departmentId: Int? = null, + val departmentName: String? = null, +) diff --git a/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsPayload.kt b/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsPayload.kt new file mode 100644 index 000000000..651502441 --- /dev/null +++ b/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsPayload.kt @@ -0,0 +1,6 @@ +package com.eatssu.common.analytics + +data class AnalyticsPayload( + val eventName: String, + val properties: Map = emptyMap(), +) diff --git a/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsTracker.kt b/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsTracker.kt new file mode 100644 index 000000000..c6d898ac9 --- /dev/null +++ b/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsTracker.kt @@ -0,0 +1,16 @@ +package com.eatssu.common.analytics + +/** + * AnalyticsTracker: 앱 코드가 의존하는 단일 진입점. + * 화면/뷰모델은 전송 SDK를 모르고 typed event만 기록한다. + * 실제 전송 대상(Firebase, PostHog 등)은 구현 내부에서 fan-out 하거나 직접 전송한다. + */ +interface AnalyticsTracker { + val id: String + + fun track(event: AnalyticsEvent) + + fun identify(identity: AnalyticsIdentity) + + fun resetIdentity() +} diff --git a/core/common/src/main/java/com/eatssu/common/enums/EventType.kt b/core/common/src/main/java/com/eatssu/common/enums/EventType.kt deleted file mode 100644 index e4ed26260..000000000 --- a/core/common/src/main/java/com/eatssu/common/enums/EventType.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.eatssu.common.enums - -import com.google.firebase.analytics.FirebaseAnalytics - -enum class EventType(val value: String) { - APP_LAUNCH("app_launch"), - CLICK_RESTAURANT_INFO("click_restaurant_info"), - SELECT_MEALTIME("select_mealtime"), - CLICK_DAY("click_day"), - CLICK_MENU("click_menu"), - WRITE_REVIEW_V1("write_review_v1"), - WRITE_REVIEW_V2("write_review_v2"), - COMPLETE_REVIEW_V1("complete_review_v1"), - COMPLETE_REVIEW_V2("complete_review_v2"), - CLICK_MAP("click_map"), - CLICK_MAP_MINE("click_map_mine"), - CLICK_PARTNER_RESTAURANT("click_partner_restaurant"), - ADD_WIDGET("add_widget"), - REMOVE_WIDGET("remove_widget"), - CHANGE_WIDGET("change_widget"), - SCREEN_VIEW(FirebaseAnalytics.Event.SCREEN_VIEW), -} \ No newline at end of file