From e4748fdf5dc90062eb2d9c5759b167695ab1cc0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A2=85=EC=88=98?= Date: Fri, 22 May 2026 16:47:50 +0900 Subject: [PATCH 1/4] =?UTF-8?q?[feat]=20Posthog=20=EC=9D=B4=EB=B2=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=8B=A0=EA=B7=9C?= =?UTF-8?q?=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../android/presentation/MainActivity.kt | 5 +- .../android/presentation/MainViewModel.kt | 33 +++++ .../event/AnyoneButMeEventPopupController.kt | 40 +++++- .../presentation/intro/IntroActivity.kt | 22 ++- .../presentation/login/LoginActivity.kt | 3 + .../presentation/login/LoginViewModel.kt | 2 +- .../android/presentation/map/MapViewModel.kt | 21 ++- .../presentation/mypage/MyPageFragment.kt | 19 +++ .../mypage/terms/TermSelectorActivity.kt | 37 ++++- .../widget/ui/WidgetSettingActivity.kt | 72 +++++++--- .../widget/ui/WidgetSettingScreen.kt | 5 - .../analytics/PostHogAnalyticsTrackerTest.kt | 65 ++++++++- .../presentation/MainViewModelBehaviorSpec.kt | 39 ++++++ .../eatssu/common/analytics/AnalyticsEvent.kt | 132 ++++++++++++++++-- 14 files changed, 441 insertions(+), 54 deletions(-) diff --git a/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt b/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt index 5b8e24584..06a0f47c6 100644 --- a/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/MainActivity.kt @@ -32,11 +32,9 @@ import com.google.android.material.bottomnavigation.BottomNavigationView import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch -import java.text.SimpleDateFormat -import java.util.Locale -import javax.inject.Inject import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import javax.inject.Inject @AndroidEntryPoint @@ -95,6 +93,7 @@ class MainActivity : BaseActivity( } R.id.anyone_but_me_menu -> { + mainViewModel.analyticsPlzNotMe() anyoneButMeEventPopupController.openAnyoneButMePage() false } 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 eace8c24b..d55234806 100644 --- a/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt @@ -15,6 +15,9 @@ import com.eatssu.android.domain.usecase.user.SetUserCollegeDepartmentUseCase import com.eatssu.common.UiEvent import com.eatssu.common.UiState import com.eatssu.common.UiText +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.ClickMyPageMenuEvent +import com.eatssu.common.analytics.ClickPlzNotMeEvent import com.eatssu.common.enums.ToastType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableSharedFlow @@ -36,6 +39,7 @@ class MainViewModel @Inject constructor( private val getUserCollegeDepartmentUseCase: GetUserCollegeDepartmentUseCase, private val getUserEmailUseCase: GetUserEmailUseCase, private val analyticsIdentityManager: AnalyticsIdentityManager, + private val analyticsTracker: AnalyticsTracker, ) : ViewModel() { private val _uiState: MutableStateFlow> = MutableStateFlow(UiState.Init) @@ -63,6 +67,35 @@ class MainViewModel @Inject constructor( } } + fun analyticsPlzNotMe() { + viewModelScope.launch { + val userCollegeDepartment = getUserCollegeDepartmentUseCase() + val newDepartmentId = userCollegeDepartment.userDepartment.departmentId.toLong() + val newCollegeId = userCollegeDepartment.userCollege.collegeId.toLong() + + analyticsTracker.track( + ClickPlzNotMeEvent( + college = newDepartmentId, + major = newCollegeId, + ), + ) + + } + } + + fun trackMyPageMenu(menu: String) { + viewModelScope.launch { + val userCollegeDepartment = getUserCollegeDepartmentUseCase() + analyticsTracker.track( + ClickMyPageMenuEvent( + college = userCollegeDepartment.userCollege.collegeId.toLong(), + major = userCollegeDepartment.userDepartment.departmentId.toLong(), + menu = menu, + ), + ) + } + } + private suspend fun fetchAndCheckNickname() { val nickname = getUserNickNameUseCase() diff --git a/app/src/main/java/com/eatssu/android/presentation/event/AnyoneButMeEventPopupController.kt b/app/src/main/java/com/eatssu/android/presentation/event/AnyoneButMeEventPopupController.kt index 497be8f69..c7b22009c 100644 --- a/app/src/main/java/com/eatssu/android/presentation/event/AnyoneButMeEventPopupController.kt +++ b/app/src/main/java/com/eatssu/android/presentation/event/AnyoneButMeEventPopupController.kt @@ -10,6 +10,8 @@ import com.eatssu.android.R import com.eatssu.android.data.local.AppFeatureDataStore import com.eatssu.android.presentation.mypage.terms.WebViewActivity import com.eatssu.android.presentation.util.openInBrowser +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.PopupEvent import com.eatssu.common.enums.ScreenId import com.eatssu.design_system.theme.EatssuTheme import dagger.hilt.android.qualifiers.ActivityContext @@ -22,7 +24,16 @@ import javax.inject.Inject class AnyoneButMeEventPopupController @Inject constructor( @ActivityContext private val context: Context, private val appFeatureDataStore: AppFeatureDataStore, + private val analyticsTracker: AnalyticsTracker, ) { + private companion object { + const val POPUP_NAME_PLZ_NOT_ME = "plz_not_me" + const val ACTION_CLICK_POPUP_IMAGE = "click_popup_image" + const val ACTION_GO_INSTA = "go_insta" + const val ACTION_NOT_SHOW_AGAIN = "not_show_again" + const val ACTION_CLOSE = "close" + } + private lateinit var composeView: ComposeView private lateinit var lifecycleScope: LifecycleCoroutineScope private var canAutoShowOnLaunch = false @@ -49,10 +60,10 @@ class AnyoneButMeEventPopupController @Inject constructor( EatssuTheme { if (isPopupVisible.value) { AnyoneButMeEventDialog( - onDismiss = ::hide, + onDismiss = ::closePopup, onDismissForever = ::dismissForever, - onInstagramClick = ::openInstagram, - onAnyoneButMeClick = ::openAnyoneButMePage + onInstagramClick = ::openInstagramFromPopup, + onAnyoneButMeClick = ::openAnyoneButMePageFromPopup ) } } @@ -71,12 +82,23 @@ class AnyoneButMeEventPopupController @Inject constructor( } private fun dismissForever() { + trackPopupAction(ACTION_NOT_SHOW_AGAIN) hide() lifecycleScope.launch { appFeatureDataStore.setAnyoneButMeEventPopupDismissed(true) } } + private fun closePopup() { + trackPopupAction(ACTION_CLOSE) + hide() + } + + private fun openAnyoneButMePageFromPopup() { + trackPopupAction(ACTION_CLICK_POPUP_IMAGE) + openAnyoneButMePage() + } + fun openAnyoneButMePage() { hide() context.startActivity( @@ -92,11 +114,21 @@ class AnyoneButMeEventPopupController @Inject constructor( ) } - private fun openInstagram() { + private fun openInstagramFromPopup() { + trackPopupAction(ACTION_GO_INSTA) hide() context.openInBrowser(context.getString(R.string.eatssu_event_instagram_url)) } + private fun trackPopupAction(action: String) { + analyticsTracker.track( + PopupEvent( + popupName = POPUP_NAME_PLZ_NOT_ME, + popupAction = action, + ), + ) + } + private fun hide() { canAutoShowOnLaunch = false isPopupVisible.value = false 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 70ddd8456..ea0066d8c 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 @@ -9,6 +9,7 @@ import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.eatssu.android.BuildConfig +import com.eatssu.android.data.local.SettingDataStore import com.eatssu.android.databinding.ActivityIntroBinding import com.eatssu.android.domain.model.AppTheme import com.eatssu.android.presentation.MainActivity @@ -27,6 +28,7 @@ import com.eatssu.common.enums.ScreenId import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -37,6 +39,9 @@ class IntroActivity : AppCompatActivity() { @Inject lateinit var analyticsTracker: AnalyticsTracker + @Inject + lateinit var settingDataStore: SettingDataStore + private val introViewModel: IntroViewModel by viewModels() private lateinit var binding: ActivityIntroBinding @@ -151,13 +156,16 @@ class IntroActivity : AppCompatActivity() { } private fun log() { - val launchPath = intent.getStringExtra("launch_path") - when (launchPath) { - "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 -> analyticsTracker.track(AppAnalyticsEvent.Launch(LaunchPath.ICON)) + lifecycleScope.launch { + val localeCode = settingDataStore.appLanguage.first().code + val launchPath = when (intent.getStringExtra("launch_path")) { + "widget" -> LaunchPath.WIDGET + "local_notification" -> LaunchPath.LOCAL_NOTIFICATION + "remote_notification" -> LaunchPath.REMOTE_NOTIFICATION + // launch_path가 없으면 일반적인 앱 아이콘 클릭으로 간주 + else -> LaunchPath.ICON + } + analyticsTracker.track(AppAnalyticsEvent.Launch(launchPath, localeCode)) } } diff --git a/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt b/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt index de593dfa0..b643e1dbf 100644 --- a/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/login/LoginActivity.kt @@ -15,6 +15,7 @@ import com.eatssu.android.presentation.util.showToast import com.eatssu.android.presentation.util.startActivity import com.eatssu.common.UiEvent import com.eatssu.common.UiState +import com.eatssu.common.analytics.CredentialsEvent import com.eatssu.common.enums.ScreenId import com.kakao.sdk.common.model.ClientError import com.kakao.sdk.common.model.ClientErrorCause @@ -59,6 +60,7 @@ class LoginActivity : //kakao login sdk를 통해 유저 정보를 가져와 rest api 호출하는 뷰모델 함수 호출 private fun handleKakaoLogin() { lifecycleScope.launch { + analyticsTracker.track(CredentialsEvent.ClickLoginEvent("kakao")) try { loginViewModel.setLoadingState() val oAuthToken = UserApiClient.loginWithKakao(this@LoginActivity) @@ -68,6 +70,7 @@ class LoginActivity : val providerID = user.id.toString() val email = user.kakaoAccount?.email.toString() loginViewModel.getKakaoLogin(email, providerID) + analyticsTracker.track(CredentialsEvent.CompleteLoginEvent("kakao")) } ?: Timber.e(error, "User info fetch failed") } } catch (error: Throwable) { 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 103c60be3..ad7e32886 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 @@ -30,7 +30,7 @@ class LoginViewModel @Inject constructor( private val setAccessTokenUseCase: SetAccessTokenUseCase, private val setRefreshTokenUseCase: SetRefreshTokenUseCase, private val setUserEmailUseCase: SetUserEmailUseCase, - private val analyticsIdentityManager: AnalyticsIdentityManager, + private val analyticsIdentityManager: AnalyticsIdentityManager ) : ViewModel() { private val _uiState = MutableStateFlow>(UiState.Init) 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 cc091d484..d9c25edb2 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 @@ -99,6 +99,14 @@ class MapViewModel @Inject constructor( FilterType.Mine -> loadUserCollegePartnerships() } + analyticsTracker.track( + MapAnalyticsEvent.MapClicked( + college = _collegeId.value, + major = _departmentId.value, + isFestival = (initialFilter == FilterType.Festival) + ), + ) + Timber.d("학과 정보 : ${userCollegeDepartment.userDepartment.departmentName}") } } @@ -131,11 +139,22 @@ class MapViewModel @Inject constructor( when (filter) { FilterType.All -> { loadPartnerships() - analyticsTracker.track(MapAnalyticsEvent.AllClicked) + analyticsTracker.track( + MapAnalyticsEvent.AllClicked( + college = _collegeId.value, + major = _departmentId.value, + ), + ) } FilterType.Festival -> { loadFestivalPartnerships() + analyticsTracker.track( + MapAnalyticsEvent.FestivalClicked( + college = _collegeId.value, + major = _departmentId.value, + ), + ) } FilterType.Mine -> { 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 ebd8f0fd1..194f59f69 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 @@ -59,6 +59,17 @@ import javax.inject.Inject @AndroidEntryPoint class MyPageFragment : Fragment() { + companion object { + private const val MENU_NOTIFICATION_SETTINGS = "notification_settings" + private const val MENU_MY_INFO = "my_info" + private const val MENU_MY_REVIEW = "my_review" + private const val MENU_INQUIRY = "inquiry" + private const val MENU_LANGUAGE_SETTING = "language_setting" + private const val MENU_CREATOR = "creator" + private const val MENU_LOGOUT = "logout" + private const val MENU_WITHDRAW = "withdraw" + } + @Inject lateinit var analyticsTracker: AnalyticsTracker @@ -99,6 +110,7 @@ class MyPageFragment : Fragment() { departmentName = departmentName, onAlarmToggle = ::handleAlarmSwitchChange, onMyInfoClick = { + mainViewModel.trackMyPageMenu(MENU_MY_INFO) startActivity( Intent( requireContext(), @@ -107,6 +119,7 @@ class MyPageFragment : Fragment() { ) }, onMyReviewClick = { + mainViewModel.trackMyPageMenu(MENU_MY_REVIEW) startActivity( Intent( requireContext(), @@ -123,6 +136,7 @@ class MyPageFragment : Fragment() { ) }, onLanguageSettingClick = { + mainViewModel.trackMyPageMenu(MENU_LANGUAGE_SETTING) startActivity( Intent( requireContext(), @@ -139,6 +153,7 @@ class MyPageFragment : Fragment() { ) }, onDeveloperClick = { + mainViewModel.trackMyPageMenu(MENU_CREATOR) startActivity( Intent( requireContext(), @@ -192,6 +207,7 @@ class MyPageFragment : Fragment() { } private fun handleAlarmSwitchChange(isChecked: Boolean) { + mainViewModel.trackMyPageMenu(MENU_NOTIFICATION_SETTINGS) if (isChecked) { if (checkNotificationPermission(requireContext())) { myPageViewModel.setNotificationOn() @@ -204,6 +220,7 @@ class MyPageFragment : Fragment() { } private fun openInquire() { + mainViewModel.trackMyPageMenu(MENU_INQUIRY) val context = requireContext() val channelPublicId = "_ZlVAn" @@ -215,6 +232,7 @@ class MyPageFragment : Fragment() { } private fun openSignOut() { + mainViewModel.trackMyPageMenu(MENU_WITHDRAW) val nickname = (myPageViewModel.uiState.value as? UiState.Success)?.data?.nickname Intent(requireContext(), SignOutActivity::class.java).apply { putExtra("nickname", nickname) @@ -268,6 +286,7 @@ class MyPageFragment : Fragment() { } private fun showLogoutDialog() { + mainViewModel.trackMyPageMenu(MENU_LOGOUT) requireContext().run { showDialog(getString(R.string.dialog_logout_title), getString(R.string.dialog_logout_message)) { isDestructive = true diff --git a/app/src/main/java/com/eatssu/android/presentation/mypage/terms/TermSelectorActivity.kt b/app/src/main/java/com/eatssu/android/presentation/mypage/terms/TermSelectorActivity.kt index a75498f83..3a445ff21 100644 --- a/app/src/main/java/com/eatssu/android/presentation/mypage/terms/TermSelectorActivity.kt +++ b/app/src/main/java/com/eatssu/android/presentation/mypage/terms/TermSelectorActivity.kt @@ -4,11 +4,31 @@ import android.content.Intent import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.lifecycle.lifecycleScope import com.eatssu.android.R +import com.eatssu.android.domain.usecase.user.GetUserCollegeDepartmentUseCase +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.ClickMyPageMenuEvent import com.eatssu.common.enums.ScreenId import com.eatssu.design_system.theme.EatssuTheme +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch +import javax.inject.Inject +@AndroidEntryPoint class TermSelectorActivity : ComponentActivity() { + + companion object { + private const val MENU_TERMS_OF_USE = "terms_of_use" + private const val MENU_PRIVACY_POLICY = "privacy_policy" + } + + @Inject + lateinit var analyticsTracker: AnalyticsTracker + + @Inject + lateinit var getUserCollegeDepartmentUseCase: GetUserCollegeDepartmentUseCase + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { @@ -16,6 +36,7 @@ class TermSelectorActivity : ComponentActivity() { TermSelectorScreen( onBack = { finish() }, onServiceRuleClick = { + trackMyPageMenu(MENU_TERMS_OF_USE) startWebView( getString(R.string.terms_url), getString(R.string.terms), @@ -23,6 +44,7 @@ class TermSelectorActivity : ComponentActivity() { ) }, onPrivateInformationClick = { + trackMyPageMenu(MENU_PRIVACY_POLICY) startWebView( getString(R.string.policy_url), getString(R.string.policy), @@ -43,4 +65,17 @@ class TermSelectorActivity : ComponentActivity() { } startActivity(intent) } -} \ No newline at end of file + + private fun trackMyPageMenu(menu: String) { + lifecycleScope.launch { + val userCollegeDepartment = getUserCollegeDepartmentUseCase() + analyticsTracker.track( + ClickMyPageMenuEvent( + college = userCollegeDepartment.userCollege.collegeId.toLong(), + major = userCollegeDepartment.userDepartment.departmentId.toLong(), + menu = menu, + ), + ) + } + } +} 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 4ef999897..aab3dd703 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 @@ -16,9 +16,11 @@ 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.LoadRestaurantByFileKeyUseCase 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.analytics.WidgetAnalyticsEvent import com.eatssu.common.enums.Restaurant import com.eatssu.design_system.theme.EatssuTheme import dagger.hilt.android.AndroidEntryPoint @@ -32,6 +34,9 @@ class WidgetSettingActivity : ComponentActivity() { @Inject lateinit var saveRestaurantByFileKeyUseCase: SaveRestaurantByFileKeyUseCase + @Inject + lateinit var loadRestaurantByFileKeyUseCase: LoadRestaurantByFileKeyUseCase + @Inject lateinit var analyticsTracker: AnalyticsTracker @@ -41,9 +46,9 @@ class WidgetSettingActivity : ComponentActivity() { ProvideAnalyticsTracker(analyticsTracker) { EatssuTheme { - val restaurantOptions = Restaurant.getVariableRestaurantList().map { - getString(it.displayNameResId) - } // 변동 식당만 불러옵니다. 하드코딩 x + val restaurants = Restaurant.getVariableRestaurantList() + val restaurantOptions = + restaurants.map { getString(it.displayNameResId) } // 변동 식당만 불러옵니다. 하드코딩 x var selectedRestaurant by rememberSaveable { mutableStateOf(restaurantOptions[0]) } @@ -60,6 +65,14 @@ class WidgetSettingActivity : ComponentActivity() { } else { null } + + val fileKey = + appWidgetId?.takeIf { it != AppWidgetManager.INVALID_APPWIDGET_ID } + ?.let { "appWidget-$it" } + val savedRestaurant = fileKey?.let { loadRestaurantByFileKeyUseCase(it) } + if (savedRestaurant != null) { + selectedRestaurant = getString(savedRestaurant.displayNameResId) + } } WidgetSettingScreen( @@ -69,38 +82,41 @@ class WidgetSettingActivity : ComponentActivity() { selectedRestaurant = displayName }, onConfirm = { selectedRestaurantValue -> - if (glanceId == null) { + val currentGlanceId = glanceId + if (currentGlanceId == null || appWidgetId == null) { finish() return@WidgetSettingScreen } lifecycleScope.launch { + val fileKey = "appWidget-$appWidgetId" + val previousRestaurant = loadRestaurantByFileKeyUseCase(fileKey) saveRestaurantByFileKeyUseCase( - "appWidget-${appWidgetId}", + fileKey, selectedRestaurantValue ) + trackWidgetSelection(previousRestaurant, selectedRestaurantValue) + // 위젯 업데이트 - glanceId?.let { - MealWidget().update(this@WidgetSettingActivity, it) - } + MealWidget().update(this@WidgetSettingActivity, currentGlanceId) // MealWorker 실행 MealWorker.enqueue(this@WidgetSettingActivity) - Timber.d("선택하기 버튼으로 저장: $selectedRestaurantValue for glanceId: $glanceId") - } + Timber.d("선택하기 버튼으로 저장: $selectedRestaurantValue for glanceId: $currentGlanceId") - // 결과 설정 - val resultIntent = Intent().apply { - putExtra( - AppWidgetManager.EXTRA_APPWIDGET_ID, - appWidgetId ?: AppWidgetManager.INVALID_APPWIDGET_ID - ) + // 결과 설정 + val resultIntent = Intent().apply { + putExtra( + AppWidgetManager.EXTRA_APPWIDGET_ID, + appWidgetId + ) + } + setResult(RESULT_OK, resultIntent) + finish() } - setResult(RESULT_OK, resultIntent) - finish() }, onBack = { finish() } ) @@ -108,4 +124,24 @@ class WidgetSettingActivity : ComponentActivity() { } } } + + private fun trackWidgetSelection( + previousRestaurant: Restaurant?, + selectedRestaurant: Restaurant, + ) { + when { + previousRestaurant == null -> { + analyticsTracker.track(WidgetAnalyticsEvent.Added(selectedRestaurant)) + } + + previousRestaurant != selectedRestaurant -> { + analyticsTracker.track( + WidgetAnalyticsEvent.Changed( + restaurantBefore = previousRestaurant, + restaurantAfter = selectedRestaurant, + ), + ) + } + } + } } 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 10681885b..a6e4e55b0 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,6 @@ 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.enums.Restaurant import com.eatssu.design_system.component.EatSsuButton @@ -34,8 +32,6 @@ fun WidgetSettingScreen( onConfirm: (Restaurant) -> Unit = {}, onBack: () -> Unit = {} // 뒤로가기 동작을 위한 람다 추가 ) { - val analyticsTracker = LocalAnalyticsTracker.current - // onClick 람다에서 LocalContext 접근이 불가하므로 Composable 레벨에서 미리 매핑 생성 val restaurantDisplayNameMap = Restaurant.getVariableRestaurantList() .associateBy { it.toUiText().asString() } @@ -77,7 +73,6 @@ fun WidgetSettingScreen( ?: Restaurant.HAKSIK onConfirm(selectedRestaurantEnum) - analyticsTracker.track(WidgetAnalyticsEvent.Added(selectedRestaurantEnum)) } ) } diff --git a/app/src/test/java/com/eatssu/android/analytics/PostHogAnalyticsTrackerTest.kt b/app/src/test/java/com/eatssu/android/analytics/PostHogAnalyticsTrackerTest.kt index 973216223..3f392d035 100644 --- a/app/src/test/java/com/eatssu/android/analytics/PostHogAnalyticsTrackerTest.kt +++ b/app/src/test/java/com/eatssu/android/analytics/PostHogAnalyticsTrackerTest.kt @@ -2,6 +2,7 @@ package com.eatssu.android.analytics import com.eatssu.common.analytics.AnalyticsIdentity import com.eatssu.common.analytics.AppAnalyticsEvent +import com.eatssu.common.analytics.PopupEvent import com.eatssu.common.analytics.WidgetAnalyticsEvent import com.eatssu.common.enums.LaunchPath import com.eatssu.common.enums.Restaurant @@ -13,10 +14,33 @@ class PostHogAnalyticsTrackerTest { @Test fun `launch payload keeps posthog compatible schema`() { - val payload = AppAnalyticsEvent.Launch(LaunchPath.WIDGET).toPayload() + val payload = AppAnalyticsEvent.Launch(LaunchPath.WIDGET, "ko").toPayload() assertEquals("app_launch", payload.eventName) - assertEquals(mapOf("launch_path" to "widget"), payload.properties) + assertEquals( + mapOf( + "launch_path" to "widget", + "localeCode" to "ko", + ), + payload.properties, + ) + } + + @Test + fun `popup event payload keeps popup name and action`() { + val payload = PopupEvent( + popupName = "plz_not_me", + popupAction = "click_popup_image", + ).toPayload() + + assertEquals("popup_event", payload.eventName) + assertEquals( + mapOf( + "popup_name" to "plz_not_me", + "popup_action" to "click_popup_image", + ), + payload.properties, + ) } @Test @@ -57,4 +81,41 @@ class PostHogAnalyticsTrackerTest { assertEquals("add_widget", payload.eventName) assertEquals(mapOf("restaurants" to "haksik"), payload.properties) } + + @Test + fun `restaurant analytics payload omits excluded restaurants`() { + val addPayload = WidgetAnalyticsEvent.Added(Restaurant.FOOD_COURT).toPayload() + val removePayload = WidgetAnalyticsEvent.Removed(Restaurant.THE_KITCHEN).toPayload() + + assertTrue(addPayload.properties.isEmpty()) + assertTrue(removePayload.properties.isEmpty()) + } + + @Test + fun `widget change payload keeps before and after restaurant keys`() { + val payload = WidgetAnalyticsEvent.Changed( + restaurantBefore = Restaurant.HAKSIK, + restaurantAfter = Restaurant.DODAM, + ).toPayload() + + assertEquals("change_widget", payload.eventName) + assertEquals( + mapOf( + "restaurant_before" to "haksik", + "restaurant_after" to "dodam", + ), + payload.properties, + ) + } + + @Test + fun `widget change payload omits excluded before and after restaurant values`() { + val payload = WidgetAnalyticsEvent.Changed( + restaurantBefore = Restaurant.FOOD_COURT, + restaurantAfter = Restaurant.THE_KITCHEN, + ).toPayload() + + assertEquals("change_widget", payload.eventName) + assertTrue(payload.properties.isEmpty()) + } } 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 9744c84a2..5c3bc310e 100644 --- a/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt @@ -15,12 +15,15 @@ import com.eatssu.android.test.AppBehaviorSpec import com.eatssu.android.test.expectToast import com.eatssu.android.test.sampleUserInfo import com.eatssu.common.UiState +import com.eatssu.common.analytics.AnalyticsTracker +import com.eatssu.common.analytics.ClickMyPageMenuEvent import com.eatssu.common.enums.ToastType import io.kotest.assertions.nondeterministic.eventually import io.kotest.matchers.shouldBe import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.delay import kotlinx.coroutines.test.advanceUntilIdle @@ -38,6 +41,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ val getUserCollegeDepartmentUseCase = mockk() val getUserEmailUseCase = mockk() val analyticsIdentityManager = mockk(relaxed = true) + val analyticsTracker = mockk(relaxed = true) val college = College(collegeId = 1, collegeName = "IT") val department = Department(departmentId = 11, departmentName = "컴퓨터학부") @@ -63,6 +67,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, + analyticsTracker = analyticsTracker, ) then("부서명이 반영된 DepartmentState로 전이된다") { @@ -96,6 +101,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, + analyticsTracker = analyticsTracker, ) advanceUntilIdle() @@ -137,6 +143,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, + analyticsTracker = analyticsTracker, ) viewModel.uiEvent.test { @@ -166,6 +173,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, + analyticsTracker = analyticsTracker, ) viewModel.uiEvent.test { @@ -186,6 +194,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, + analyticsTracker = analyticsTracker, ) then("로그아웃 유즈케이스 호출 후 성공 토스트와 LoggedOut 상태를 반영한다") { @@ -204,5 +213,35 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ } } + `when`("마이페이지 메뉴 클릭을 로깅하면") { + val viewModel = MainViewModel( + logoutUseCase = logoutUseCase, + getUserNickNameUseCase = getUserNickNameUseCase, + setUserCollegeDepartmentUseCase = setUserCollegeDepartmentUseCase, + userRepository = userRepository, + getUserCollegeDepartmentUseCase = getUserCollegeDepartmentUseCase, + getUserEmailUseCase = getUserEmailUseCase, + analyticsIdentityManager = analyticsIdentityManager, + analyticsTracker = analyticsTracker, + ) + + then("사용자 학과/단과대와 메뉴명을 이벤트에 담아 전송한다") { + runTest { + viewModel.trackMyPageMenu("my_info") + advanceUntilIdle() + + verify { + analyticsTracker.track( + ClickMyPageMenuEvent( + college = 1L, + major = 11L, + menu = "my_info", + ), + ) + } + } + } + } + } }) 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 index 75b969b12..396d2b762 100644 --- a/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsEvent.kt +++ b/core/common/src/main/java/com/eatssu/common/analytics/AnalyticsEvent.kt @@ -18,10 +18,12 @@ sealed interface AnalyticsEvent { sealed interface AppAnalyticsEvent : AnalyticsEvent { data class Launch( val launchPath: LaunchPath, + val localeCode: String, ) : AppAnalyticsEvent { override val eventName = "app_launch" override val properties = buildMap { put("launch_path", launchPath.value) + put("localeCode", localeCode) } } } @@ -32,7 +34,7 @@ sealed interface CafeteriaAnalyticsEvent : AnalyticsEvent { ) : CafeteriaAnalyticsEvent { override val eventName = "click_restaurant_info" override val properties = buildMap { - put("restaurants", restaurant.value) + putRestaurant("restaurants", restaurant) } } @@ -48,7 +50,7 @@ sealed interface CafeteriaAnalyticsEvent : AnalyticsEvent { data class DaySelected( val day: String, ) : CafeteriaAnalyticsEvent { - override val eventName = "click_day" + override val eventName = "select_day" override val properties = buildMap { put("day", day.toWeekdayCode()) } @@ -59,35 +61,61 @@ sealed interface CafeteriaAnalyticsEvent : AnalyticsEvent { ) : CafeteriaAnalyticsEvent { override val eventName = "click_menu" override val properties = buildMap { - put("restaurants", restaurant.value) + putRestaurant("restaurants", restaurant) } } } sealed interface ReviewAnalyticsEvent : AnalyticsEvent { - object WriteClicked : ReviewAnalyticsEvent { + data class WriteClicked( + val restaurant: Restaurant, + ) : ReviewAnalyticsEvent { override val eventName = "write_review_v2" - override val properties = emptyMap() + override val properties = buildMap { + putRestaurant("restaurants", restaurant) + } } data class Completed( val rating: Long, val likes: Long, val photoAttached: Boolean, + val restaurant: Restaurant, ) : ReviewAnalyticsEvent { override val eventName = "complete_review_v2" override val properties = buildMap { put("rating", rating) put("likes", likes) - put("photo_attached", photoAttached) + put("photo_attached", if (photoAttached) 1 else 0) + putRestaurant("restaurants", restaurant) } } } sealed interface MapAnalyticsEvent : AnalyticsEvent { - object AllClicked : MapAnalyticsEvent { + + data class MapClicked( + val college: Long, + val major: Long, + val isFestival: Boolean, + ) : MapAnalyticsEvent { override val eventName = "click_map" - override val properties = emptyMap() + override val properties = buildMap { + put("college", college) + put("major", major) + put("default_type", if (isFestival) "festival" else "general") + } + } + + data class AllClicked( + val college: Long, + val major: Long, + ) : MapAnalyticsEvent { + override val eventName = "click_map_all" + override val properties = buildMap { + put("college", college) + put("major", major) + } } data class MineClicked( @@ -101,6 +129,17 @@ sealed interface MapAnalyticsEvent : AnalyticsEvent { } } + data class FestivalClicked( + val college: Long, + val major: Long, + ) : MapAnalyticsEvent { + override val eventName = "click_map_festival" + override val properties = buildMap { + put("college", college) + put("major", major) + } + } + data class PartnerRestaurantClicked( val college: Long, val major: Long, @@ -121,7 +160,7 @@ sealed interface WidgetAnalyticsEvent : AnalyticsEvent { ) : WidgetAnalyticsEvent { override val eventName = "add_widget" override val properties = buildMap { - put("restaurants", restaurant.value) + putRestaurant("restaurants", restaurant) } } @@ -130,16 +169,18 @@ sealed interface WidgetAnalyticsEvent : AnalyticsEvent { ) : WidgetAnalyticsEvent { override val eventName = "remove_widget" override val properties = buildMap { - restaurant?.let { put("restaurants", it.value) } + restaurant?.let { putRestaurant("restaurants", it) } } } data class Changed( - val restaurant: Restaurant, + val restaurantBefore: Restaurant, + val restaurantAfter: Restaurant, ) : WidgetAnalyticsEvent { override val eventName = "change_widget" override val properties = buildMap { - put("restaurants", restaurant.value) + putRestaurant("restaurant_before", restaurantBefore) + putRestaurant("restaurant_after", restaurantAfter) } } } @@ -155,6 +196,63 @@ data class ScreenViewEvent( } } +sealed interface CredentialsEvent : AnalyticsEvent { + data class ClickLoginEvent( + val loginMethod: String + ) : CredentialsEvent { + override val eventName = "click_login" + override val properties = buildMap { + put("method", loginMethod) + } + } + + data class CompleteLoginEvent( + val loginMethod: String + ) : CredentialsEvent { + override val eventName = "complete_login" + override val properties = buildMap { + put("method", loginMethod) + } + } +} + +data class ClickPlzNotMeEvent( + val college: Long, + val major: Long, +) : AppAnalyticsEvent { + override val eventName = "click_plz_not_me" + override val properties = buildMap { + put("college", college) + put("major", major) + } +} + +data class ClickMyPageMenuEvent( + val college: Long, + val major: Long, + val menu: String, +) : AppAnalyticsEvent { + override val eventName = "click_mypage_menu" + override val properties = buildMap { + put("college", college) + put("major", major) + put("menu", menu) + } +} + +data class PopupEvent( + val popupName: String, + val popupAction: String, +) : AppAnalyticsEvent { + override val eventName = "popup_event" + override val properties = buildMap { + put("popup_name", popupName) + put("popup_action", popupAction) + } +} + + + private fun String.toWeekdayCode() = when (this) { "SUNDAY" -> "sun" "MONDAY" -> "mon" @@ -165,3 +263,13 @@ private fun String.toWeekdayCode() = when (this) { "SATURDAY" -> "sat" else -> "" } + +private fun MutableMap.putRestaurant(key: String, restaurant: Restaurant) { + if (restaurant in analyticsExcludedRestaurants) return + put(key, restaurant.value) +} + +private val analyticsExcludedRestaurants = setOf( + Restaurant.FOOD_COURT, + Restaurant.THE_KITCHEN, +) From 50bdb23cca6508ed080867400c76a0038b19666d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A2=85=EC=88=98?= Date: Fri, 22 May 2026 16:58:03 +0900 Subject: [PATCH 2/4] =?UTF-8?q?[fix]=20=EB=A6=AC=EB=B7=B0=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20=EC=8B=9D=EB=8B=B9=20=ED=8C=8C=EB=9D=BC?= =?UTF-8?q?=EB=AF=B8=ED=84=B0=20=EC=A0=84=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cafeteria/menu/MenuSubAdapter.kt | 1 + .../cafeteria/review/ReviewComposeActivity.kt | 13 +++++++++---- .../cafeteria/review/ReviewNav.kt | 4 ++++ .../cafeteria/review/list/ReviewListScreen.kt | 10 +++++++++- .../review/write/WriteReviewScreen.kt | 4 +++- .../review/write/WriteReviewViewModel.kt | 3 +++ .../analytics/DefaultAnalyticsTrackerTest.kt | 8 +++++++- .../analytics/FirebaseAnalyticsTrackerTest.kt | 7 +++++-- .../write/WriteReviewViewModelBehaviorSpec.kt | 19 +++++++++++-------- 9 files changed, 52 insertions(+), 17 deletions(-) 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 b67331ed5..6805fe63d 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 @@ -43,6 +43,7 @@ class MenuSubAdapter( val item = dataList[position] val intent = Intent(binding.root.context, ReviewComposeActivity::class.java) + intent.putExtra("restaurant", restaurant.name) when (restaurant.menuType) { MenuType.FIXED -> { 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 d25f81b1e..2c0f7c656 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 @@ -22,6 +22,7 @@ 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.common.enums.Restaurant import com.eatssu.design_system.theme.EatssuTheme import com.google.firebase.crashlytics.FirebaseCrashlytics import dagger.hilt.android.AndroidEntryPoint @@ -36,6 +37,7 @@ class ReviewComposeActivity : ComponentActivity() { lateinit var analyticsTracker: AnalyticsTracker private var menuType: String? = null + private var restaurant: String? = null private var itemId by Delegates.notNull() private lateinit var itemName: String @@ -48,17 +50,19 @@ class ReviewComposeActivity : ComponentActivity() { EatssuTheme { val navHostController = rememberNavController() val parsedMenuType = MenuType.entries.find { it.name == menuType } + val parsedRestaurant = Restaurant.entries.find { it.name == restaurant } - parsedMenuType?.let { type -> + if (parsedMenuType != null && parsedRestaurant != null) { ReviewNav( navHostController = navHostController, - menuType = type, + menuType = parsedMenuType, + restaurant = parsedRestaurant, menuName = itemName, id = itemId, onExit = { finish() } ) - } ?: run { - Timber.e("Invalid or null MenuType received: $menuType") + } else { + Timber.e("Invalid review parameters received: menuType=$menuType, restaurant=$restaurant") ErrorScreen( onBackClick = { finish() } ) @@ -70,6 +74,7 @@ class ReviewComposeActivity : ComponentActivity() { private fun getIntents() { //todo 추후 변경 menuType = intent.getStringExtra("menuType") + restaurant = intent.getStringExtra("restaurant") itemId = intent.getLongExtra("itemId", 0) itemName = intent.getStringExtra("itemName").toString().replace(Regex("[\\[\\]]"), "") diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewNav.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewNav.kt index 2dd825fb1..5da3048f8 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewNav.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/ReviewNav.kt @@ -13,6 +13,7 @@ import com.eatssu.android.presentation.cafeteria.review.list.ReviewListScreen import com.eatssu.android.presentation.cafeteria.review.modify.ModifyReviewScreen import com.eatssu.android.presentation.cafeteria.review.write.WriteReviewScreen import com.eatssu.common.enums.MenuType +import com.eatssu.common.enums.Restaurant object ReviewNav { const val List = "list" @@ -27,6 +28,7 @@ fun ReviewNav( navHostController: NavHostController = rememberNavController(), menuName: String, menuType: MenuType, + restaurant: Restaurant, id: Long, onExit: () -> Unit = {} ) { @@ -44,6 +46,7 @@ fun ReviewNav( ReviewListScreen( menuName = menuName, menuType = menuType, + restaurant = restaurant, id = id, refreshNonce = refreshNonce, onBack = { onExit() }, @@ -76,6 +79,7 @@ fun ReviewNav( composable(ReviewNav.Write) { backStackEntry -> WriteReviewScreen( menuType = menuType, + restaurant = restaurant, menuName = menuName, id = id, onBack = { 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 b25b1f3ae..ae55778a9 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 @@ -59,6 +59,7 @@ 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.Restaurant import com.eatssu.common.enums.ScreenId import com.eatssu.common.enums.ToastType import com.eatssu.design_system.component.DelayedLoadingIndicator @@ -76,6 +77,7 @@ fun ReviewListScreen( modifier: Modifier = Modifier, viewModel: ReviewListViewModel = hiltViewModel(), menuType: MenuType, + restaurant: Restaurant, menuName: String, id: Long, refreshNonce: Long = 0L, @@ -125,6 +127,7 @@ fun ReviewListScreen( reviewPagingItems = reviewPagingItems, modifier = modifier, menuName = menuName, + restaurant = restaurant, onBack = onBack, onReviewWriteButtonClick = onWriteButtonClick, onModifyClick = onModifyClick, @@ -138,6 +141,7 @@ internal fun ReviewListScreen( reviewPagingItems: LazyPagingItems, modifier: Modifier = Modifier, menuName: String, + restaurant: Restaurant, onBack: () -> Unit = {}, onReviewWriteButtonClick: () -> Unit, onModifyClick: (Review) -> Unit, @@ -192,7 +196,7 @@ internal fun ReviewListScreen( text = stringResource(R.string.review_write), onClick = { onReviewWriteButtonClick() - analyticsTracker.track(ReviewAnalyticsEvent.WriteClicked) + analyticsTracker.track(ReviewAnalyticsEvent.WriteClicked(restaurant)) }, modifier = Modifier .padding(24.dp) @@ -628,6 +632,7 @@ fun ReviewListPreview() { EatssuTheme { ReviewListScreen( menuName = "소고기+닭고기+돼지고기+양고기+오리고기", + restaurant = Restaurant.HAKSIK, onReviewWriteButtonClick = {}, onModifyClick = {}, onDeleteClick = {}, @@ -663,6 +668,7 @@ fun ReviewListLoadingPreview() { EatssuTheme { ReviewListScreen( menuName = "소고기+닭고기+돼지고기+양고기+오리고기", + restaurant = Restaurant.HAKSIK, onReviewWriteButtonClick = {}, onModifyClick = {}, onDeleteClick = {}, @@ -698,6 +704,7 @@ fun ReviewListEmptyPreview() { EatssuTheme { ReviewListScreen( menuName = "소고기+닭고기+돼지고기+양고기+오리고기+닭고기+돼지고기+양고기", + restaurant = Restaurant.HAKSIK, onReviewWriteButtonClick = {}, onModifyClick = {}, onDeleteClick = {}, @@ -733,6 +740,7 @@ fun ReviewListErrorPreview() { EatssuTheme { ReviewListScreen( menuName = "소고기+닭고기+돼지고기+양고기+오리고기", + restaurant = Restaurant.HAKSIK, onReviewWriteButtonClick = {}, onModifyClick = {}, onDeleteClick = {}, diff --git a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt index 9aeb6e235..6b9befbb6 100644 --- a/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt +++ b/app/src/main/java/com/eatssu/android/presentation/cafeteria/review/write/WriteReviewScreen.kt @@ -50,6 +50,7 @@ import com.eatssu.android.presentation.util.showToast import com.eatssu.common.UiEvent import com.eatssu.common.UiState import com.eatssu.common.enums.MenuType +import com.eatssu.common.enums.Restaurant import com.eatssu.common.enums.ScreenId import com.eatssu.design_system.component.CloseTopBar import com.eatssu.design_system.component.EatSsuButton @@ -72,6 +73,7 @@ fun WriteReviewScreen( viewModel: WriteReviewViewModel = hiltViewModel(), menuName: String, menuType: MenuType, + restaurant: Restaurant, id: Long, onBack: () -> Unit, ) { @@ -121,7 +123,7 @@ fun WriteReviewScreen( onToggleLike = viewModel::toggleLike, onImageSelect = { galleryLauncher.launch("image/*") }, onImageDelete = { viewModel.setSelectedImage(null) }, - onSubmit = { viewModel.postReview(menuType, id, context) } + onSubmit = { viewModel.postReview(menuType, restaurant, id, context) } ) } 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 15dec9649..2619d6cca 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 @@ -15,6 +15,7 @@ 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.Restaurant import com.eatssu.common.enums.ToastType import dagger.hilt.android.lifecycle.HiltViewModel import id.zelory.compressor.Compressor @@ -88,6 +89,7 @@ class WriteReviewViewModel @Inject constructor( fun postReview( menuType: MenuType, + restaurant: Restaurant, itemId: Long, context: Context, ) { @@ -167,6 +169,7 @@ class WriteReviewViewModel @Inject constructor( rating = editing.rating.toLong(), likes = editing.likedMenuIds.size.toLong(), photoAttached = editing.selectedImageUri != null, + restaurant = restaurant, ), ) diff --git a/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt b/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt index b3ee49a27..bb3bd022e 100644 --- a/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt +++ b/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt @@ -5,6 +5,7 @@ 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 com.eatssu.common.enums.Restaurant import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test @@ -16,7 +17,12 @@ class DefaultAnalyticsTrackerTest { 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) + val event = ReviewAnalyticsEvent.Completed( + rating = 5L, + likes = 2L, + photoAttached = true, + restaurant = Restaurant.HAKSIK, + ) analyticsTracker.track(event) diff --git a/app/src/test/java/com/eatssu/android/analytics/FirebaseAnalyticsTrackerTest.kt b/app/src/test/java/com/eatssu/android/analytics/FirebaseAnalyticsTrackerTest.kt index 460638f93..8ceb8de9b 100644 --- a/app/src/test/java/com/eatssu/android/analytics/FirebaseAnalyticsTrackerTest.kt +++ b/app/src/test/java/com/eatssu/android/analytics/FirebaseAnalyticsTrackerTest.kt @@ -2,6 +2,7 @@ package com.eatssu.android.analytics import com.eatssu.common.analytics.ReviewAnalyticsEvent import com.eatssu.common.analytics.ScreenViewEvent +import com.eatssu.common.enums.Restaurant import com.eatssu.common.enums.ScreenId import org.junit.Assert.assertEquals import org.junit.Test @@ -26,11 +27,12 @@ class FirebaseAnalyticsTrackerTest { } @Test - fun `review completion payload keeps firebase compatible boolean value`() { + fun `review completion payload keeps restaurant and photo value`() { val payload = ReviewAnalyticsEvent.Completed( rating = 5L, likes = 2L, photoAttached = true, + restaurant = Restaurant.HAKSIK, ).toPayload() assertEquals("complete_review_v2", payload.eventName) @@ -38,7 +40,8 @@ class FirebaseAnalyticsTrackerTest { mapOf( "rating" to 5L, "likes" to 2L, - "photo_attached" to true, + "photo_attached" to 1, + "restaurants" to "haksik", ), payload.properties, ) 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 ce370d3b8..958826264 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 @@ -17,6 +17,7 @@ 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.Restaurant import com.eatssu.common.enums.ToastType import id.zelory.compressor.Compressor import io.kotest.matchers.shouldBe @@ -95,7 +96,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.loadMenuList(MenuType.FIXED, 1L, "돈가스") advanceUntilIdle() - viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true)) + viewModel.postReview(MenuType.FIXED, Restaurant.HAKSIK, 1L, mockk(relaxed = true)) advanceUntilIdle() coVerify(exactly = 0) { @@ -110,7 +111,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ then("아무 동작도 수행하지 않는다") { runTest { - viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true)) + viewModel.postReview(MenuType.FIXED, Restaurant.HAKSIK, 1L, mockk(relaxed = true)) advanceUntilIdle() viewModel.uiState.value shouldBe UiState.Init @@ -159,7 +160,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.onContentChanged("good") viewModel.uiEvent.test { - viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true)) + viewModel.postReview(MenuType.FIXED, Restaurant.HAKSIK, 1L, mockk(relaxed = true)) advanceUntilIdle() expectToast(R.string.toast_review_write_success, ToastType.SUCCESS) @@ -170,6 +171,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ rating = 5, likes = 0, photoAttached = false, + restaurant = Restaurant.HAKSIK, ), ) } @@ -207,7 +209,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ clearMocks(writeReviewUseCase, answers = false, recordedCalls = true) viewModel.uiEvent.test { - viewModel.postReview(MenuType.FIXED, 1L, context) + viewModel.postReview(MenuType.FIXED, Restaurant.HAKSIK, 1L, context) advanceUntilIdle() expectToast(R.string.toast_image_upload_success, ToastType.SUCCESS) @@ -219,6 +221,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ rating = 4, likes = 0, photoAttached = true, + restaurant = Restaurant.HAKSIK, ), ) } @@ -252,7 +255,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.setSelectedImage(uri) viewModel.uiEvent.test { - viewModel.postReview(MenuType.FIXED, 1L, context) + viewModel.postReview(MenuType.FIXED, Restaurant.HAKSIK, 1L, context) advanceUntilIdle() expectToast(R.string.toast_image_upload_failed, ToastType.ERROR) @@ -286,7 +289,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.setSelectedImage(uri) viewModel.uiEvent.test { - viewModel.postReview(MenuType.FIXED, 1L, context) + viewModel.postReview(MenuType.FIXED, Restaurant.HAKSIK, 1L, context) advanceUntilIdle() expectToast(R.string.toast_image_compress_failed, ToastType.ERROR) @@ -315,7 +318,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.setSelectedImage(uri) viewModel.uiEvent.test { - viewModel.postReview(MenuType.FIXED, 1L, context) + viewModel.postReview(MenuType.FIXED, Restaurant.HAKSIK, 1L, context) advanceUntilIdle() expectToast(R.string.toast_image_upload_failed, ToastType.ERROR) @@ -336,7 +339,7 @@ class WriteReviewViewModelBehaviorSpec : AppBehaviorSpec({ viewModel.onRatingChanged(3) viewModel.uiEvent.test { - viewModel.postReview(MenuType.FIXED, 1L, mockk(relaxed = true)) + viewModel.postReview(MenuType.FIXED, Restaurant.HAKSIK, 1L, mockk(relaxed = true)) advanceUntilIdle() viewModel.uiState.value.successDataAs() From 57ada38b5c3b9d889ba4c707bcb9a5125a7783ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A2=85=EC=88=98?= Date: Fri, 22 May 2026 17:10:58 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[bugfix]=20Github=20Action=EC=97=90?= =?UTF-8?q?=EC=84=9C=20Test=EC=97=90=20=EC=8B=A4=ED=8C=A8=ED=95=98?= =?UTF-8?q?=EB=8D=98=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt | 6 +++--- .../android/presentation/map/MapViewModelBehaviorSpec.kt | 6 +++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt b/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt index bb3bd022e..841bdc704 100644 --- a/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt +++ b/app/src/test/java/com/eatssu/android/analytics/DefaultAnalyticsTrackerTest.kt @@ -2,8 +2,8 @@ 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.MapAnalyticsEvent import com.eatssu.common.analytics.ReviewAnalyticsEvent import com.eatssu.common.enums.Restaurant import org.junit.Assert.assertEquals @@ -36,7 +36,7 @@ class DefaultAnalyticsTrackerTest { val second = FakeAnalyticsTracker(id = "duplicate") val analyticsTracker = DefaultAnalyticsTracker(setOf(first, second)) - analyticsTracker.track(MapAnalyticsEvent.AllClicked) + analyticsTracker.track(MapAnalyticsEvent.AllClicked(college = -1L, major = -1L)) assertEquals(1, first.events.size + second.events.size) } @@ -63,7 +63,7 @@ class DefaultAnalyticsTrackerTest { val failingTracker = FakeAnalyticsTracker(id = "firebase", failOnTrack = true) val healthyTracker = FakeAnalyticsTracker(id = "posthog") val analyticsTracker = DefaultAnalyticsTracker(setOf(failingTracker, healthyTracker)) - val event = MapAnalyticsEvent.AllClicked + val event = MapAnalyticsEvent.AllClicked(college = -1L, major = -1L) analyticsTracker.track(event) 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 ef922e9c1..92e0b49cf 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 @@ -139,7 +139,11 @@ class MapViewModelBehaviorSpec : AppBehaviorSpec({ allState.data.selectedFilter shouldBe FilterType.All allState.data.partnerships shouldBe allPartnerships } - verify(atLeast = 1) { analyticsTracker.track(MapAnalyticsEvent.AllClicked) } + verify(atLeast = 1) { + analyticsTracker.track( + MapAnalyticsEvent.AllClicked(college = 1L, major = 11L), + ) + } viewModel.setFilter(FilterType.Mine) eventually(2.seconds) { From 12b732cd4eebddee6028e72ec4be971f9d300b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B5=9C=EC=A2=85=EC=88=98?= Date: Sat, 23 May 2026 22:15:49 +0900 Subject: [PATCH 4/4] =?UTF-8?q?Merge=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20Build?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/eatssu/android/presentation/MainViewModel.kt | 3 +++ .../com/eatssu/android/presentation/mypage/MyPageFragment.kt | 2 ++ .../eatssu/android/presentation/MainViewModelBehaviorSpec.kt | 1 + 3 files changed, 6 insertions(+) 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 e425c8fb2..38609248c 100644 --- a/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt +++ b/app/src/main/java/com/eatssu/android/presentation/MainViewModel.kt @@ -97,6 +97,9 @@ class MainViewModel @Inject constructor( menu = menu, ), ) + } + } + fun refreshUserDepartmentFromServer() { viewModelScope.launch { loadUserDepartmentFromServer() 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 ddede2042..66e5d9d41 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 @@ -144,6 +144,7 @@ class MyPageFragment : Fragment() { ) }, onLanguageSettingClick = { + mainViewModel.trackMyPageMenu(MENU_LANGUAGE_SETTING) languageSelectorLauncher.launch( Intent( requireContext(), @@ -160,6 +161,7 @@ class MyPageFragment : Fragment() { ) }, onDeveloperClick = { + mainViewModel.trackMyPageMenu(MENU_CREATOR) startWebView( getString(R.string.developer_url), getString(R.string.developer), 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 514a4202c..0c45aa458 100644 --- a/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt +++ b/app/src/test/java/com/eatssu/android/presentation/MainViewModelBehaviorSpec.kt @@ -236,6 +236,7 @@ class MainViewModelBehaviorSpec : AppBehaviorSpec({ getUserEmailUseCase = getUserEmailUseCase, analyticsIdentityManager = analyticsIdentityManager, analyticsTracker = analyticsTracker, + settingDataStore = settingDataStore, ) then("사용자 학과/단과대와 메뉴명을 이벤트에 담아 전송한다") {