diff --git a/.github/workflows/pr_builder.yml b/.github/workflows/pr_builder.yml index 35ea0476b..dfca92693 100644 --- a/.github/workflows/pr_builder.yml +++ b/.github/workflows/pr_builder.yml @@ -47,6 +47,7 @@ jobs: RELEASE_KEY_PASSWORD: ${{ secrets.RELEASE_KEY_PASSWORD }} RELEASE_STORE_PASSWORD: ${{ secrets.RELEASE_STORE_PASSWORD }} NATIVE_APP_KEY: ${{ secrets.NATIVE_APP_KEY }} + MIXPANEL_KEY: ${{ secrets.MIXPANEL_KEY }} run: | echo prod.base.url=\"$PROD_BASE_URL\" >> local.properties echo dev.base.url=\"$DEV_BASE_URL\" >> local.properties @@ -57,6 +58,8 @@ jobs: echo storePassword=$RELEASE_STORE_PASSWORD >> local.properties echo native.app.key=\"$NATIVE_APP_KEY\" >> local.properties echo nativeAppKey=$NATIVE_APP_KEY >> local.properties + echo mixpanelDevKey=\"$MIXPANEL_KEY\" >> local.properties + echo mixpanelProdKey=\"$MIXPANEL_KEY\" >> local.properties - name: Create Google Services JSON env: diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 78fa93d93..dd8c5d8b8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -56,6 +56,12 @@ android { "BASE_URL", properties.getProperty("dev.base.url") ) + + buildConfigField( + "String", + "MIXPANEL_KEY", + properties["mixpanelDevKey"] as? String ?: "" + ) } release { @@ -65,6 +71,12 @@ android { properties.getProperty("prod.base.url") ) + buildConfigField( + "String", + "MIXPANEL_KEY", + properties["mixpanelProdKey"] as? String ?: "" + ) + isMinifyEnabled = true isShrinkResources = true proguardFiles( @@ -146,6 +158,8 @@ dependencies { implementation(libs.pebble) implementation(libs.jakewharton.process.phoenix) implementation(libs.play.services.oss.licenses) + + implementation(libs.mixpanel) } ktlint { diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/MixPanelTracker.kt b/app/src/main/java/com/spoony/spoony/core/analytics/MixPanelTracker.kt new file mode 100644 index 000000000..4d7030805 --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/MixPanelTracker.kt @@ -0,0 +1,40 @@ +package com.spoony.spoony.core.analytics + +import android.content.Context +import com.mixpanel.android.mpmetrics.MixpanelAPI +import com.spoony.spoony.BuildConfig.MIXPANEL_KEY +import dagger.hilt.android.qualifiers.ApplicationContext +import jakarta.inject.Inject +import org.json.JSONObject +import timber.log.Timber + +class MixPanelTracker @Inject constructor( + @ApplicationContext private val context: Context +) { + private val mixpanel = MixpanelAPI.getInstance( + context, + MIXPANEL_KEY, + false + ) + + fun setUserProfile(userId: String, properties: Map) { + mixpanel.identify(userId) + properties.forEach { (key, value) -> + mixpanel.people.set(key, value) + } + } + + fun resetUserProfile() { + mixpanel.reset() + } + + fun track(eventName: String) { + Timber.tag("mixpanel").d(eventName) + mixpanel.track(eventName) + } + + fun track(eventName: String, properties: JSONObject) { + Timber.tag("mixpanel").d("$eventName $properties") + mixpanel.track(eventName, properties) + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/AnalyticsEvents.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/AnalyticsEvents.kt new file mode 100644 index 000000000..230e122d2 --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/AnalyticsEvents.kt @@ -0,0 +1,26 @@ +package com.spoony.spoony.core.analytics.events + +import com.spoony.spoony.core.analytics.MixPanelTracker +import jakarta.inject.Inject +import org.json.JSONObject + +class AnalyticsEvents @Inject constructor( + private val tracker: MixPanelTracker +) { + fun appOpen() { + tracker.track("app_open") + } + + fun signupCompleted(signupMethod: String) { + tracker.track( + eventName = "signup_completed", + properties = JSONObject().apply { + put("signup_method", signupMethod) + } + ) + } + + fun loginSuccess() { + tracker.track("login_success") + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/CommonEvents.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/CommonEvents.kt new file mode 100644 index 000000000..05a7c7a0c --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/CommonEvents.kt @@ -0,0 +1,169 @@ +package com.spoony.spoony.core.analytics.events + +import com.spoony.spoony.core.analytics.MixPanelTracker +import com.spoony.spoony.core.analytics.model.ReviewTrackingModel +import jakarta.inject.Inject +import org.json.JSONArray +import org.json.JSONObject + +class CommonEvents @Inject constructor( + private val tracker: MixPanelTracker +) { + fun tabEntered(tabName: String) { + tracker.track( + eventName = "tab_entered", + properties = JSONObject().apply { + put("tab_name", tabName) + } + ) + } + + fun reviewViewed( + reviewTrackingModel: ReviewTrackingModel, + isSelfReview: Boolean, + isFollowedUserReview: Boolean, + isSavedReview: Boolean + ) { + tracker.track( + eventName = "review_viewed", + properties = JSONObject().apply { + put("review_id", reviewTrackingModel.reviewId) + put("author_user_id", reviewTrackingModel.authorUserId) + put("place_name", reviewTrackingModel.placeName) + put("category", reviewTrackingModel.category) + put("menu_count", reviewTrackingModel.menuCount) + put("satisfaction_score", reviewTrackingModel.satisfactionScore) + put("review_length", reviewTrackingModel.reviewLength) + put("photo_count", reviewTrackingModel.photoCount) + put("has_disappointment", reviewTrackingModel.hasDisappointment) + put("saved_count", reviewTrackingModel.savedCount) + put("is_self_review", isSelfReview) + put("is_followed_user_review", isFollowedUserReview) + put("is_saved_review", isSavedReview) + } + ) + } + + fun reviewEdited( + reviewTrackingModel: ReviewTrackingModel + ) { + tracker.track( + eventName = "review_edited", + properties = JSONObject().apply { + put("review_id", reviewTrackingModel.reviewId) + put("author_user_id", reviewTrackingModel.authorUserId) + put("place_name", reviewTrackingModel.placeName) + put("category", reviewTrackingModel.category) + put("menu_count", reviewTrackingModel.menuCount) + put("satisfaction_score", reviewTrackingModel.satisfactionScore) + put("review_length", reviewTrackingModel.reviewLength) + put("photo_count", reviewTrackingModel.photoCount) + put("has_disappointment", reviewTrackingModel.hasDisappointment) + put("saved_count", reviewTrackingModel.savedCount) + } + ) + } + + fun profileViewed( + profileUserId: Int, + isSelfProfile: Boolean, + isFollowingProfileUser: Boolean +// entryPoint: String + ) { + tracker.track( + eventName = "profile_viewed", + properties = JSONObject().apply { + put("profile_user_id", profileUserId) + put("is_self_profile", isSelfProfile) + put("is_following_profile_user", isFollowingProfileUser) + } + ) + } + + fun followUser( + followedUserId: Int, + entryPoint: String + ) { + tracker.track( + eventName = "follow_user", + properties = JSONObject().apply { + put("followed_user_id", followedUserId) + put("entry_point", entryPoint) + } + ) + } + + fun unfollowUser( + unfollowedUserId: Int, + entryPoint: String + ) { + tracker.track( + eventName = "unfollow_user", + properties = JSONObject().apply { + put("unfollowed_user_id", unfollowedUserId) + put("entry_point", entryPoint) + } + ) + } + + fun followUserFromReview( + reviewTrackingModel: ReviewTrackingModel + ) { + tracker.track( + eventName = "follow_user_from_review", + properties = JSONObject().apply { + put("review_id", reviewTrackingModel.reviewId) + put("author_user_id", reviewTrackingModel.authorUserId) + put("place_name", reviewTrackingModel.placeName) + put("category", reviewTrackingModel.category) + put("menu_count", reviewTrackingModel.menuCount) + put("satisfaction_score", reviewTrackingModel.satisfactionScore) + put("review_length", reviewTrackingModel.reviewLength) + put("photo_count", reviewTrackingModel.photoCount) + put("has_disappointment", reviewTrackingModel.hasDisappointment) + put("saved_count", reviewTrackingModel.savedCount) + put("entry_point", "review") + } + ) + } + + fun unfollowUserFromReview( + reviewTrackingModel: ReviewTrackingModel + ) { + tracker.track( + eventName = "unfollow_user_from_review", + properties = JSONObject().apply { + put("review_id", reviewTrackingModel.reviewId) + put("author_user_id", reviewTrackingModel.authorUserId) + put("place_name", reviewTrackingModel.placeName) + put("category", reviewTrackingModel.category) + put("menu_count", reviewTrackingModel.menuCount) + put("satisfaction_score", reviewTrackingModel.satisfactionScore) + put("review_length", reviewTrackingModel.reviewLength) + put("photo_count", reviewTrackingModel.photoCount) + put("has_disappointment", reviewTrackingModel.hasDisappointment) + put("saved_count", reviewTrackingModel.savedCount) + put("entry_point", "review") + } + ) + } + + fun filterApplied( + pageApplied: String, + localReviewFilter: Boolean? = null, + regionFilters: List = listOf(), + categoryFilters: List = listOf(), + ageGroupFilters: List = listOf() + ) { + tracker.track( + eventName = "filter_applied", + properties = JSONObject().apply { + put("page_applied", pageApplied) + put("local_review_filter", localReviewFilter) + put("region_filters", JSONArray(regionFilters)) + put("category_filters", JSONArray(categoryFilters)) + put("age_group_filters", JSONArray(ageGroupFilters)) + } + ) + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/ExploreEvents.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/ExploreEvents.kt new file mode 100644 index 000000000..eb83e1f23 --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/ExploreEvents.kt @@ -0,0 +1,31 @@ +package com.spoony.spoony.core.analytics.events + +import com.spoony.spoony.core.analytics.MixPanelTracker +import jakarta.inject.Inject +import org.json.JSONObject + +class ExploreEvents @Inject constructor( + private val tracker: MixPanelTracker +) { + fun sortSelected(sortType: String) { + tracker.track( + eventName = "sort_selected", + properties = JSONObject().apply { + put("sort_type", sortType) + } + ) + } + + fun exploreSearched( + searchTargetType: String, + searchTerm: String + ) { + tracker.track( + eventName = "explore_searched", + properties = JSONObject().apply { + put("search_target_type", searchTargetType) + put("search_term", searchTerm) + } + ) + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/MapEvents.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/MapEvents.kt new file mode 100644 index 000000000..ff9b0d91e --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/MapEvents.kt @@ -0,0 +1,22 @@ +package com.spoony.spoony.core.analytics.events + +import com.spoony.spoony.core.analytics.MixPanelTracker +import jakarta.inject.Inject +import org.json.JSONObject + +class MapEvents @Inject constructor( + private val tracker: MixPanelTracker +) { + fun mapSearched( + locationType: String, + searchTerm: String + ) { + tracker.track( + eventName = "map_searched", + properties = JSONObject().apply { + put("location_type", locationType) + put("search_term", searchTerm) + } + ) + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/MixPanelEvents.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/MixPanelEvents.kt new file mode 100644 index 000000000..4d10e2507 --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/MixPanelEvents.kt @@ -0,0 +1,21 @@ +package com.spoony.spoony.core.analytics.events + +import androidx.compose.runtime.staticCompositionLocalOf +import jakarta.inject.Inject + +val LocalTracker = staticCompositionLocalOf { + error("No MixPanelEvents provided") +} + +class MixPanelEvents @Inject constructor( + val userProperties: MixPanelUserProperties, + val analyticsEvents: AnalyticsEvents, + val commonEvents: CommonEvents, + val onboardingEvents: OnboardingEvents, + val spoonDrawEvents: SpoonDrawEvents, + val mapEvents: MapEvents, + val exploreEvents: ExploreEvents, + val registerEvents: RegisterEvents, + val mypageEvents: MypageEvents, + val reviewDetailEvents: ReviewDetailEvents +) diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/MixPanelUserProperties.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/MixPanelUserProperties.kt new file mode 100644 index 000000000..24e28069d --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/MixPanelUserProperties.kt @@ -0,0 +1,16 @@ +package com.spoony.spoony.core.analytics.events + +import com.spoony.spoony.core.analytics.MixPanelTracker +import jakarta.inject.Inject + +class MixPanelUserProperties @Inject constructor( + private val tracker: MixPanelTracker +) { + fun setUserProfile(userId: String, properties: Map) { + tracker.setUserProfile(userId = userId, properties = properties) + } + + fun resetUserProfile() { + tracker.resetUserProfile() + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/MypageEvents.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/MypageEvents.kt new file mode 100644 index 000000000..93b7c54fc --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/MypageEvents.kt @@ -0,0 +1,23 @@ +package com.spoony.spoony.core.analytics.events + +import com.spoony.spoony.core.analytics.MixPanelTracker +import jakarta.inject.Inject +import org.json.JSONArray +import org.json.JSONObject + +class MypageEvents @Inject constructor( + private val tracker: MixPanelTracker +) { + fun profileUpdated(fieldsUpdated: List = listOf()) { + tracker.track( + eventName = "profile_updated", + properties = JSONObject().apply { + put("fields_updated", JSONArray(fieldsUpdated)) + } + ) + } + + fun spoonCharacterViewed() { + tracker.track("spoon_character_viewed") + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/OnboardingEvents.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/OnboardingEvents.kt new file mode 100644 index 000000000..483c849ea --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/OnboardingEvents.kt @@ -0,0 +1,43 @@ +package com.spoony.spoony.core.analytics.events + +import com.spoony.spoony.core.analytics.MixPanelTracker +import jakarta.inject.Inject +import org.json.JSONObject + +class OnboardingEvents @Inject constructor( + private val tracker: MixPanelTracker +) { + fun onboard1Completed() { + tracker.track("onboard_1_completed") + } + + fun onboard2Completed( + isBirthdateEntered: Boolean, + isActiveRegionEntered: Boolean + ) { + tracker.track( + eventName = "onboard_2_completed", + properties = JSONObject().apply { + put("birthdate_entered", isBirthdateEntered) + put("active_region_entered", isActiveRegionEntered) + } + ) + } + + fun onboard2Skipped() { + tracker.track("onboard_2_skipped") + } + + fun onboard3Completed(bioLength: Int) { + tracker.track( + eventName = "onboard_3_completed", + properties = JSONObject().apply { + put("bio_length", bioLength) + } + ) + } + + fun onboard3Skipped() { + tracker.track("onboard_3_skipped") + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/RegisterEvents.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/RegisterEvents.kt new file mode 100644 index 000000000..30408eb66 --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/RegisterEvents.kt @@ -0,0 +1,39 @@ +package com.spoony.spoony.core.analytics.events + +import com.spoony.spoony.core.analytics.MixPanelTracker +import jakarta.inject.Inject +import org.json.JSONObject + +class RegisterEvents @Inject constructor( + private val tracker: MixPanelTracker +) { + fun review1Completed( + placeName: String, + category: String, + menuCount: Int + ) { + tracker.track( + eventName = "review_1_completed", + properties = JSONObject().apply { + put("place_name", placeName) + put("category", category) + put("menu_count", menuCount) + } + ) + } + + fun review2Completed( + reviewLength: Int, + photoCount: Int, + hasDisappointment: Boolean + ) { + tracker.track( + eventName = "review_2_completed", + properties = JSONObject().apply { + put("review_length", reviewLength) + put("photo_count", photoCount) + put("has_disappointment", hasDisappointment) + } + ) + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/ReviewDetailEvents.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/ReviewDetailEvents.kt new file mode 100644 index 000000000..ba3f557ac --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/ReviewDetailEvents.kt @@ -0,0 +1,124 @@ +package com.spoony.spoony.core.analytics.events + +import com.spoony.spoony.core.analytics.MixPanelTracker +import com.spoony.spoony.core.analytics.model.ReviewTrackingModel +import jakarta.inject.Inject +import org.json.JSONObject + +class ReviewDetailEvents @Inject constructor( + private val tracker: MixPanelTracker +) { + fun spoonUseIntent( + reviewTrackingModel: ReviewTrackingModel, + isFollowingAuthor: Boolean + ) { + tracker.track( + eventName = "spoon_use_intent", + properties = JSONObject().apply { + put("review_id", reviewTrackingModel.reviewId) + put("author_user_id", reviewTrackingModel.authorUserId) + put("place_name", reviewTrackingModel.placeName) + put("category", reviewTrackingModel.category) + put("menu_count", reviewTrackingModel.menuCount) + put("satisfaction_score", reviewTrackingModel.satisfactionScore) + put("review_length", reviewTrackingModel.reviewLength) + put("photo_count", reviewTrackingModel.photoCount) + put("has_disappointment", reviewTrackingModel.hasDisappointment) + put("saved_count", reviewTrackingModel.savedCount) + put("is_following_author", isFollowingAuthor) + } + ) + } + + fun spoonUsed( + reviewTrackingModel: ReviewTrackingModel, + isFollowingAuthor: Boolean + ) { + tracker.track( + eventName = "spoon_used", + properties = JSONObject().apply { + put("review_id", reviewTrackingModel.reviewId) + put("author_user_id", reviewTrackingModel.authorUserId) + put("place_name", reviewTrackingModel.placeName) + put("category", reviewTrackingModel.category) + put("menu_count", reviewTrackingModel.menuCount) + put("satisfaction_score", reviewTrackingModel.satisfactionScore) + put("review_length", reviewTrackingModel.reviewLength) + put("photo_count", reviewTrackingModel.photoCount) + put("has_disappointment", reviewTrackingModel.hasDisappointment) + put("saved_count", reviewTrackingModel.savedCount) + put("is_following_author", isFollowingAuthor) + } + ) + } + + fun spoonUseFailed() { + tracker.track("spoon_use_failed") + } + + fun placeMapSaved( + reviewTrackingModel: ReviewTrackingModel, + isFollowingAuthor: Boolean + ) { + tracker.track( + eventName = "place_map_saved", + properties = JSONObject().apply { + put("review_id", reviewTrackingModel.reviewId) + put("author_user_id", reviewTrackingModel.authorUserId) + put("place_name", reviewTrackingModel.placeName) + put("category", reviewTrackingModel.category) + put("menu_count", reviewTrackingModel.menuCount) + put("satisfaction_score", reviewTrackingModel.satisfactionScore) + put("review_length", reviewTrackingModel.reviewLength) + put("photo_count", reviewTrackingModel.photoCount) + put("has_disappointment", reviewTrackingModel.hasDisappointment) + put("saved_count", reviewTrackingModel.savedCount) + put("is_following_author", isFollowingAuthor) + } + ) + } + + fun placeMapRemoved( + reviewTrackingModel: ReviewTrackingModel, + isFollowingAuthor: Boolean + ) { + tracker.track( + eventName = "place_map_removed", + properties = JSONObject().apply { + put("review_id", reviewTrackingModel.reviewId) + put("author_user_id", reviewTrackingModel.authorUserId) + put("place_name", reviewTrackingModel.placeName) + put("category", reviewTrackingModel.category) + put("menu_count", reviewTrackingModel.menuCount) + put("satisfaction_score", reviewTrackingModel.satisfactionScore) + put("review_length", reviewTrackingModel.reviewLength) + put("photo_count", reviewTrackingModel.photoCount) + put("has_disappointment", reviewTrackingModel.hasDisappointment) + put("saved_count", reviewTrackingModel.savedCount) + put("is_following_author", isFollowingAuthor) + } + ) + } + + fun directionClicked( + reviewTrackingModel: ReviewTrackingModel, + isFollowingAuthor: Boolean + ) { + tracker.track( + eventName = "direction_clicked", + properties = JSONObject().apply { + put("review_id", reviewTrackingModel.reviewId) + put("author_user_id", reviewTrackingModel.authorUserId) + put("place_name", reviewTrackingModel.placeName) + put("category", reviewTrackingModel.category) + put("menu_count", reviewTrackingModel.menuCount) + put("satisfaction_score", reviewTrackingModel.satisfactionScore) + put("review_length", reviewTrackingModel.reviewLength) + put("photo_count", reviewTrackingModel.photoCount) + put("has_disappointment", reviewTrackingModel.hasDisappointment) + put("saved_count", reviewTrackingModel.savedCount) + put("is_following_author", isFollowingAuthor) + } + ) + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/events/SpoonDrawEvents.kt b/app/src/main/java/com/spoony/spoony/core/analytics/events/SpoonDrawEvents.kt new file mode 100644 index 000000000..254674239 --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/events/SpoonDrawEvents.kt @@ -0,0 +1,18 @@ +package com.spoony.spoony.core.analytics.events + +import com.spoony.spoony.core.analytics.MixPanelTracker +import jakarta.inject.Inject +import org.json.JSONObject + +class SpoonDrawEvents @Inject constructor( + private val tracker: MixPanelTracker +) { + fun spoonReceived(spoonCount: Int) { + tracker.track( + eventName = "spoon_received", + properties = JSONObject().apply { + put("spoon_count", spoonCount) + } + ) + } +} diff --git a/app/src/main/java/com/spoony/spoony/core/analytics/model/ReviewTrackingModel.kt b/app/src/main/java/com/spoony/spoony/core/analytics/model/ReviewTrackingModel.kt new file mode 100644 index 000000000..dccf878f3 --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/core/analytics/model/ReviewTrackingModel.kt @@ -0,0 +1,14 @@ +package com.spoony.spoony.core.analytics.model + +data class ReviewTrackingModel( + val reviewId: Int, + val authorUserId: Int, + val placeName: String, + val category: String, + val menuCount: Int, + val satisfactionScore: Double, + val reviewLength: Int, + val photoCount: Int, + val hasDisappointment: Boolean, + val savedCount: Int +) diff --git a/app/src/main/java/com/spoony/spoony/core/designsystem/component/dialog/SpoonDrawDialog.kt b/app/src/main/java/com/spoony/spoony/core/designsystem/component/dialog/SpoonDrawDialog.kt index b52d38260..7b9e0cbc6 100644 --- a/app/src/main/java/com/spoony/spoony/core/designsystem/component/dialog/SpoonDrawDialog.kt +++ b/app/src/main/java/com/spoony/spoony/core/designsystem/component/dialog/SpoonDrawDialog.kt @@ -19,6 +19,7 @@ import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.rememberLottieAnimatable import com.airbnb.lottie.compose.rememberLottieComposition import com.spoony.spoony.R +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.image.SpoonyImage import com.spoony.spoony.core.designsystem.model.SpoonDrawModel import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme @@ -34,6 +35,7 @@ fun SpoonDrawDialog( onSpoonDrawButtonClick: suspend () -> SpoonDrawModel, onConfirmButtonClick: () -> Unit ) { + val tracker = LocalTracker.current val coroutineScope = rememberCoroutineScope() var dialogState by remember { mutableStateOf(SpoonDrawDialogState.DRAW) } @@ -88,6 +90,8 @@ fun SpoonDrawDialog( } SpoonDrawDialogState.RESULT -> { + tracker.spoonDrawEvents.spoonReceived(drawResult.spoonAmount) + TitleButtonDialog( title = "${drawResult.spoonName} 획득", description = "축하해요!\n총 ${drawResult.spoonAmount}개의 스푼을 적립했어요.", diff --git a/app/src/main/java/com/spoony/spoony/data/mapper/LocationMapper.kt b/app/src/main/java/com/spoony/spoony/data/mapper/LocationMapper.kt index 6ca3136d7..00725fede 100644 --- a/app/src/main/java/com/spoony/spoony/data/mapper/LocationMapper.kt +++ b/app/src/main/java/com/spoony/spoony/data/mapper/LocationMapper.kt @@ -6,6 +6,7 @@ import com.spoony.spoony.domain.entity.LocationEntity fun LocationListResponseDto.LocationResponseDto.toDomain(): LocationEntity = LocationEntity( locationId = this.locationId, locationName = this.locationName, + locationType = this.locationType.locationTypeName, locationAddress = this.locationAddress, scope = this.locationType.scope, latitude = this.latitude, diff --git a/app/src/main/java/com/spoony/spoony/domain/entity/LocationEntity.kt b/app/src/main/java/com/spoony/spoony/domain/entity/LocationEntity.kt index b86c795b3..17b675b7d 100644 --- a/app/src/main/java/com/spoony/spoony/domain/entity/LocationEntity.kt +++ b/app/src/main/java/com/spoony/spoony/domain/entity/LocationEntity.kt @@ -8,6 +8,7 @@ data class LocationEntity( val locationId: Int, val locationName: String, val locationAddress: String, + val locationType: String, val scope: Double, val latitude: Double, val longitude: Double diff --git a/app/src/main/java/com/spoony/spoony/presentation/MainActivity.kt b/app/src/main/java/com/spoony/spoony/presentation/MainActivity.kt index 7b30fb37e..d53e22b5b 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/MainActivity.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/MainActivity.kt @@ -6,12 +6,19 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.runtime.CompositionLocalProvider +import com.spoony.spoony.core.analytics.events.LocalTracker +import com.spoony.spoony.core.analytics.events.MixPanelEvents import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme import com.spoony.spoony.presentation.main.MainScreen import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject @AndroidEntryPoint class MainActivity : ComponentActivity() { + @Inject + lateinit var mixPanelEvents: MixPanelEvents + @SuppressLint("SourceLockedOrientationActivity") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -19,7 +26,9 @@ class MainActivity : ComponentActivity() { enableEdgeToEdge() setContent { SpoonyAndroidTheme { - MainScreen() + CompositionLocalProvider(LocalTracker provides mixPanelEvents) { + MainScreen() + } } } } diff --git a/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingEndScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingEndScreen.kt index 4c083e5c1..cdd6f653d 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingEndScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingEndScreen.kt @@ -23,6 +23,7 @@ import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.rememberLottieComposition import com.spoony.spoony.R +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.button.SpoonyButton import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme import com.spoony.spoony.core.designsystem.type.ButtonSize @@ -35,8 +36,11 @@ fun OnboardingEndRoute( ) { val state by viewModel.state.collectAsStateWithLifecycle() + val tracker = LocalTracker.current + LaunchedEffect(Unit) { viewModel.updateCurrentStep(OnboardingSteps.END) + tracker.analyticsEvents.signupCompleted("kakao") } OnboardingEndScreen( diff --git a/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingScreen.kt index e8a8329ff..2f0f9734f 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingScreen.kt @@ -17,6 +17,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController import androidx.navigation.navOptions +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.topappbar.SpoonyBasicTopAppBar import com.spoony.spoony.core.designsystem.event.LocalSnackBarTrigger import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme @@ -47,10 +48,12 @@ private fun OnboardingScreen( val navController = rememberNavController() val state by viewModel.state.collectAsStateWithLifecycle() val showSnackbar = LocalSnackBarTrigger.current + val tracker = LocalTracker.current when (state.signUpState) { is UiState.Empty -> { viewModel.updateCurrentStep(OnboardingSteps.END) + navController.navigate( route = End, navOptions = navOptions { @@ -78,6 +81,7 @@ private fun OnboardingScreen( onBackButtonClick = navController::navigateUp, onSkipButtonClick = { viewModel.skipStep() + tracker.onboardingEvents.onboard2Skipped() navController.navigate(OnboardingRoute.StepThree) } ) @@ -89,6 +93,7 @@ private fun OnboardingScreen( onSkipButtonClick = { viewModel.skipStep() viewModel.signUp() + tracker.onboardingEvents.onboard3Skipped() } ) } diff --git a/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepOneScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepOneScreen.kt index 177eefde9..86cb747b7 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepOneScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepOneScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.textfield.NicknameTextFieldState import com.spoony.spoony.core.designsystem.component.textfield.SpoonyNicknameTextField import com.spoony.spoony.core.designsystem.event.LocalSnackBarTrigger @@ -30,6 +31,7 @@ fun OnBoardingStepOneRoute( val state by viewModel.state.collectAsStateWithLifecycle() val lifecycleOwner = LocalLifecycleOwner.current val showSnackbar = LocalSnackBarTrigger.current + val tracker = LocalTracker.current LaunchedEffect(Unit) { viewModel.updateCurrentStep(OnboardingSteps.ONE) @@ -52,7 +54,10 @@ fun OnBoardingStepOneRoute( onNicknameChanged = viewModel::updateNickname, onStateChanged = viewModel::updateNicknameState, checkNicknameValid = viewModel::checkUserNameExist, - onButtonClick = onNextButtonClick + onButtonClick = { + onNextButtonClick() + tracker.onboardingEvents.onboard1Completed() + } ) } diff --git a/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepThreeScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepThreeScreen.kt index b603c26ce..850cef6ad 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepThreeScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepThreeScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.textfield.SpoonyLargeTextField import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme import com.spoony.spoony.core.util.extension.addFocusCleaner @@ -22,6 +23,7 @@ fun OnboardingStepThreeRoute( viewModel: OnboardingViewModel ) { val state by viewModel.state.collectAsStateWithLifecycle() + val tracker = LocalTracker.current LaunchedEffect(Unit) { viewModel.updateCurrentStep(OnboardingSteps.THREE) @@ -30,7 +32,10 @@ fun OnboardingStepThreeRoute( OnboardingStepThreeScreen( introduction = state.introduction, onValueChanged = viewModel::updateIntroduction, - onButtonClick = viewModel::signUp + onButtonClick = { + viewModel.signUp() + tracker.onboardingEvents.onboard3Completed(state.introduction?.length ?: 0) + } ) } diff --git a/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepTwoScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepTwoScreen.kt index dc141e51a..390046dbe 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepTwoScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/auth/onboarding/OnboardingStepTwoScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.bottomsheet.SpoonyDatePickerBottomSheet import com.spoony.spoony.core.designsystem.component.bottomsheet.SpoonyRegionBottomSheet import com.spoony.spoony.core.designsystem.component.button.RegionSelectButton @@ -36,6 +37,7 @@ fun OnboardingStepTwoRoute( ) { val state by viewModel.state.collectAsStateWithLifecycle() val showSnackbar = LocalSnackBarTrigger.current + val tracker = LocalTracker.current var isButtonEnabled by remember { mutableStateOf(false) } var birthBottomSheetVisibility by remember { mutableStateOf(false) } @@ -66,7 +68,13 @@ fun OnboardingStepTwoRoute( regionBottomSheetVisibility = true viewModel.getRegionList() }, - onNextButtonClick = onNextButtonClick + onNextButtonClick = { + onNextButtonClick() + tracker.onboardingEvents.onboard2Completed( + isBirthdateEntered = !state.birth.isNullOrBlank(), + isActiveRegionEntered = state.region != null + ) + } ) if (birthBottomSheetVisibility) { diff --git a/app/src/main/java/com/spoony/spoony/presentation/auth/signin/SignInScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/auth/signin/SignInScreen.kt index 4c4b91a28..cb309e24a 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/auth/signin/SignInScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/auth/signin/SignInScreen.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.flowWithLifecycle import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.kakao.sdk.user.UserApiClient import com.spoony.spoony.R +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.event.LocalSnackBarTrigger import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme import com.spoony.spoony.core.designsystem.theme.main100 @@ -42,6 +43,8 @@ fun SignInRoute( val systemUiController = rememberSystemUiController() val showSnackbar = LocalSnackBarTrigger.current + val tracker = LocalTracker.current + LaunchedEffect(Unit) { systemUiController.setNavigationBarColor( color = main100 @@ -54,7 +57,12 @@ fun SignInRoute( when (sideEffect) { is SignInSideEffect.ShowSnackBar -> showSnackbar(sideEffect.message) is SignInSideEffect.NavigateToSignUp -> navigateToTermsOfService() - is SignInSideEffect.NavigateToMap -> navigateToMap() + is SignInSideEffect.NavigateToMap -> { + tracker.analyticsEvents.loginSuccess() + + navigateToMap() + } + is SignInSideEffect.StartKakaoTalkLogin -> { UserApiClient.instance.loginWithKakaoTalk( context = context, diff --git a/app/src/main/java/com/spoony/spoony/presentation/explore/ExploreScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/explore/ExploreScreen.kt index e9546e1f2..7f0dfd3e8 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/explore/ExploreScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/explore/ExploreScreen.kt @@ -36,6 +36,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.repeatOnLifecycle +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.card.ReviewCard import com.spoony.spoony.core.designsystem.component.pullToRefresh.SpoonyPullToRefreshContainer import com.spoony.spoony.core.designsystem.event.LocalSnackBarTrigger @@ -73,19 +74,28 @@ fun ExploreRoute( val lifecycleOwner = LocalLifecycleOwner.current val showSnackBar = LocalSnackBarTrigger.current + val tracker = LocalTracker.current + val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() + + LaunchedEffect(Unit) { + tracker.commonEvents.tabEntered("explore") + } + LaunchedEffect(viewModel.sideEffect, lifecycleOwner) { viewModel.sideEffect.flowWithLifecycle(lifecycleOwner.lifecycle).collect { effect -> when (effect) { is ExploreSideEffect.ShowSnackbar -> { showSnackBar(effect.message) } + is ExploreSideEffect.ScrollToTop -> { coroutineScope.launch { listState.scrollToItem(0) } } + is ExploreSideEffect.NavigateToSearch -> navigateToExploreSearch() is ExploreSideEffect.NavigateToRegister -> navigateToRegister() is ExploreSideEffect.NavigateToPlaceDetail -> navigateToPlaceDetail(effect.id) diff --git a/app/src/main/java/com/spoony/spoony/presentation/explore/component/ExploreFilterSection.kt b/app/src/main/java/com/spoony/spoony/presentation/explore/component/ExploreFilterSection.kt index 7bbf9ba10..a3966cd61 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/explore/component/ExploreFilterSection.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/explore/component/ExploreFilterSection.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.presentation.explore.ExploreAction import com.spoony.spoony.presentation.explore.ExploreFilterItems import com.spoony.spoony.presentation.explore.ExploreFilterState @@ -30,6 +31,8 @@ fun ExploreFilterSection( filterItems: ExploreFilterItems, onAction: (ExploreAction) -> Unit ) { + val tracker = LocalTracker.current + var isSortingBottomSheetVisible by remember { mutableStateOf(false) } var isFilterBottomSheetVisible by remember { mutableStateOf(false) } var exploreFilterBottomSheetTabIndex by remember { mutableIntStateOf(0) } @@ -48,7 +51,14 @@ fun ExploreFilterSection( onFilterClick = { filterType -> handleFilterClick( filterType = filterType, - onLocalReviewButtonClick = { onAction(ExploreAction.ClickLocalReview) }, + onLocalReviewButtonClick = { + tracker.commonEvents.filterApplied( + pageApplied = "explore", + localReviewFilter = !(selectedFilterState.properties[2] ?: false) + ) + + onAction(ExploreAction.ClickLocalReview) + }, updateBottomSheetState = { index, isVisible -> exploreFilterBottomSheetTabIndex = index isFilterBottomSheetVisible = isVisible @@ -62,7 +72,10 @@ fun ExploreFilterSection( if (isSortingBottomSheetVisible) { ExploreSortingBottomSheet( onDismiss = { isSortingBottomSheetVisible = false }, - onClick = { onAction(ExploreAction.ChangeSorting(it)) }, + onClick = { sortType -> + onAction(ExploreAction.ChangeSorting(sortType)) + tracker.exploreEvents.sortSelected(sortType.trackingCode) + }, currentSortingOption = selectedSortingOption ) } diff --git a/app/src/main/java/com/spoony/spoony/presentation/explore/type/SortingOption.kt b/app/src/main/java/com/spoony/spoony/presentation/explore/type/SortingOption.kt index f4aa3d1f3..538501dfd 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/explore/type/SortingOption.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/explore/type/SortingOption.kt @@ -2,8 +2,9 @@ package com.spoony.spoony.presentation.explore.type enum class SortingOption( val stringValue: String, - val stringCode: String + val stringCode: String, + val trackingCode: String ) { - LATEST("최신순", "createdAt"), - POPULARITY("저장 많은 순", "zzimCount") + LATEST("최신순", "createdAt", "latest"), + POPULARITY("저장 많은 순", "zzimCount", "most_saved") } diff --git a/app/src/main/java/com/spoony/spoony/presentation/exploreSearch/ExploreSearchScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/exploreSearch/ExploreSearchScreen.kt index 61e47fbdc..de09b121b 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/exploreSearch/ExploreSearchScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/exploreSearch/ExploreSearchScreen.kt @@ -39,6 +39,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.repeatOnLifecycle +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.card.ReviewCard import com.spoony.spoony.core.designsystem.component.dialog.TwoButtonDialog import com.spoony.spoony.core.designsystem.event.LocalSnackBarTrigger @@ -120,6 +121,8 @@ private fun ExploreSearchScreen( placeReviewInfoList: UiState>, onAction: (ExploreSearchAction) -> Unit ) { + val tracker = LocalTracker.current + val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current var tabRowIndex by rememberSaveable { mutableIntStateOf(0) } @@ -167,6 +170,10 @@ private fun ExploreSearchScreen( onBackButtonClick = { onAction(ExploreSearchAction.ClickBack) }, onSearchAction = { onAction(ExploreSearchAction.Search(searchText)) + tracker.exploreEvents.exploreSearched( + searchTargetType = searchType.trackingCode, + searchTerm = searchText + ) }, focusRequester = focusRequester, searchType = searchType @@ -229,6 +236,7 @@ private fun ExploreSearchScreen( ) } } + searchKeyword.isBlank() && searchText.isNotBlank() -> {} else -> { when (userInfoList) { @@ -255,14 +263,17 @@ private fun ExploreSearchScreen( } } } + is UiState.Empty -> { ExploreSearchEmptyScreen(searchType = searchType) } - else -> { } + + else -> {} } } } } + 1 -> { when { searchKeyword.isBlank() && searchText.isBlank() -> { @@ -281,6 +292,7 @@ private fun ExploreSearchScreen( ) } } + searchKeyword.isBlank() && searchText.isNotBlank() -> {} else -> { when (placeReviewInfoList) { @@ -339,10 +351,12 @@ private fun ExploreSearchScreen( } } } + is UiState.Empty -> { ExploreSearchEmptyScreen(searchType = searchType) } - else -> { } + + else -> {} } } } diff --git a/app/src/main/java/com/spoony/spoony/presentation/exploreSearch/type/SearchType.kt b/app/src/main/java/com/spoony/spoony/presentation/exploreSearch/type/SearchType.kt index f30c40e00..fd27768bd 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/exploreSearch/type/SearchType.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/exploreSearch/type/SearchType.kt @@ -1,8 +1,10 @@ package com.spoony.spoony.presentation.exploreSearch.type -enum class SearchType { - USER, - REVIEW +enum class SearchType( + val trackingCode: String +) { + USER("user"), + REVIEW("review") } fun SearchType.toKoreanText(): String { diff --git a/app/src/main/java/com/spoony/spoony/presentation/follow/FollowRoute.kt b/app/src/main/java/com/spoony/spoony/presentation/follow/FollowRoute.kt index ba5750ea9..a5110b02a 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/follow/FollowRoute.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/follow/FollowRoute.kt @@ -169,7 +169,8 @@ private fun FollowScreen( users = users, onUserClick = onUserClick, onMyClick = onMyClick, - onButtonClick = onFollowButtonClick + onButtonClick = onFollowButtonClick, + type = type ) } diff --git a/app/src/main/java/com/spoony/spoony/presentation/follow/component/UserListScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/follow/component/UserListScreen.kt index bca76cade..763c44b47 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/follow/component/UserListScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/follow/component/UserListScreen.kt @@ -8,6 +8,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp +import com.spoony.spoony.core.analytics.events.LocalTracker +import com.spoony.spoony.presentation.follow.model.FollowType import com.spoony.spoony.presentation.follow.model.UserItemUiState import kotlinx.collections.immutable.ImmutableList @@ -17,8 +19,11 @@ fun UserListScreen( onUserClick: (Int) -> Unit, onMyClick: () -> Unit, onButtonClick: (Int) -> Unit, + type: FollowType, modifier: Modifier = Modifier ) { + val tracker = LocalTracker.current + LazyColumn( modifier = modifier.fillMaxSize(), contentPadding = PaddingValues(horizontal = 20.dp, vertical = 10.dp) @@ -35,7 +40,39 @@ fun UserListScreen( region = if (user.region.isNullOrBlank()) "" else "서울 ${user.region} 스푼", isFollowing = user.isFollowing, onUserClick = { if (user.isMe) onMyClick() else onUserClick(user.userId) }, - onFollowClick = { onButtonClick(user.userId) }, + onFollowClick = { + onButtonClick(user.userId) + + when (type) { + FollowType.FOLLOWER -> { + if (user.isFollowing) { + tracker.commonEvents.unfollowUser( + unfollowedUserId = user.userId, + entryPoint = "followed_list" + ) + } else { + tracker.commonEvents.followUser( + followedUserId = user.userId, + entryPoint = "followed_list" + ) + } + } + + FollowType.FOLLOWING -> { + if (user.isFollowing) { + tracker.commonEvents.unfollowUser( + unfollowedUserId = user.userId, + entryPoint = "following_list" + ) + } else { + tracker.commonEvents.followUser( + followedUserId = user.userId, + entryPoint = "following_list" + ) + } + } + } + }, modifier = Modifier.padding(vertical = 10.dp) ) } diff --git a/app/src/main/java/com/spoony/spoony/presentation/gourmet/map/MapScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/gourmet/map/MapScreen.kt index 8c6aefaed..32b28d17c 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/gourmet/map/MapScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/gourmet/map/MapScreen.kt @@ -78,6 +78,7 @@ import com.naver.maps.map.compose.NaverMap import com.naver.maps.map.compose.rememberCameraPositionState import com.naver.maps.map.location.FusedLocationSource import com.spoony.spoony.R +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.bottomsheet.SpoonyAdvancedBottomSheet import com.spoony.spoony.core.designsystem.component.bottomsheet.SpoonyBasicDragHandle import com.spoony.spoony.core.designsystem.component.chip.IconChip @@ -132,6 +133,7 @@ fun MapRoute( val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current val showSnackBar = LocalSnackBarTrigger.current + val tracker = LocalTracker.current val cameraPositionState = rememberCameraPositionState { position = CameraPosition( @@ -176,6 +178,7 @@ fun MapRoute( with(state.locationModel) { LaunchedEffect(placeId) { if (placeId == null) { + tracker.commonEvents.tabEntered("map") viewModel.getAddedPlaceList(DEFAULT_CATEGORY_ID) } else { viewModel.getAddedPlaceListByLocation(locationId = placeId) @@ -313,6 +316,7 @@ private fun MapScreen( onGpsButtonClick: () -> Unit, onCategoryClick: (Int) -> Unit ) { + val tracker = LocalTracker.current val density = LocalDensity.current val sheetState = rememberBottomSheetState( @@ -476,6 +480,11 @@ private fun MapScreen( onClick = { selectedCategoryId = categoryId onCategoryClick(categoryId) + + tracker.commonEvents.filterApplied( + pageApplied = "map", + regionFilters = listOf(categoryName) + ) }, isSelected = categoryId == selectedCategoryId, isGradient = true, diff --git a/app/src/main/java/com/spoony/spoony/presentation/gourmet/map/model/LocationModel.kt b/app/src/main/java/com/spoony/spoony/presentation/gourmet/map/model/LocationModel.kt index 3ebe4033e..7387d7148 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/gourmet/map/model/LocationModel.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/gourmet/map/model/LocationModel.kt @@ -6,6 +6,7 @@ data class LocationModel( val placeId: Int? = null, val placeName: String? = null, val locationAddress: String? = null, + val locationType: String? = null, val scale: Double = 14.0, val latitude: Double = 0.0, val longitude: Double = 0.0 @@ -15,6 +16,7 @@ fun LocationEntity.toModel(): LocationModel = LocationModel( placeId = this.locationId, placeName = this.locationName, locationAddress = this.locationAddress, + locationType = this.locationType, scale = this.scope, latitude = this.latitude, longitude = this.longitude diff --git a/app/src/main/java/com/spoony/spoony/presentation/gourmet/search/MapSearchScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/gourmet/search/MapSearchScreen.kt index 864e75ebb..f1df94d65 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/gourmet/search/MapSearchScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/gourmet/search/MapSearchScreen.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme import com.spoony.spoony.core.state.UiState import com.spoony.spoony.core.util.extension.noRippleClickable @@ -76,6 +77,7 @@ private fun MapSearchScreen( ) { val focusRequester = remember { FocusRequester() } val focusManager = LocalFocusManager.current + val tracker = LocalTracker.current LaunchedEffect(Unit) { focusRequester.requestFocus() @@ -168,6 +170,11 @@ private fun MapSearchScreen( modifier = Modifier .background(SpoonyAndroidTheme.colors.white) .noRippleClickable { + tracker.mapEvents.mapSearched( + locationType = locationInfo.locationType.orEmpty(), + searchTerm = searchKeyword + ) + onResultItemClick( locationInfo.placeId ?: 0, locationInfo.placeName ?: "", diff --git a/app/src/main/java/com/spoony/spoony/presentation/placeDetail/PlaceDetailRoute.kt b/app/src/main/java/com/spoony/spoony/presentation/placeDetail/PlaceDetailRoute.kt index c3aa7dd8d..61e7af321 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/placeDetail/PlaceDetailRoute.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/placeDetail/PlaceDetailRoute.kt @@ -38,6 +38,8 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.repeatOnLifecycle +import com.spoony.spoony.core.analytics.events.LocalTracker +import com.spoony.spoony.core.analytics.model.ReviewTrackingModel import com.spoony.spoony.core.designsystem.component.button.FollowButton import com.spoony.spoony.core.designsystem.component.snackbar.TextSnackbar import com.spoony.spoony.core.designsystem.component.topappbar.TagTopAppBar @@ -76,6 +78,7 @@ fun PlaceDetailRoute( viewModel: PlaceDetailViewModel = hiltViewModel() ) { val lifecycleOwner = LocalLifecycleOwner.current + val tracker = LocalTracker.current val state by viewModel.state.collectAsStateWithLifecycle(lifecycleOwner = lifecycleOwner) @@ -101,6 +104,7 @@ fun PlaceDetailRoute( is PlaceDetailSideEffect.ShowSnackbar -> { onShowSnackBar(effect.message) } + is PlaceDetailSideEffect.NavigateUp -> navigateUp() } } @@ -130,17 +134,59 @@ fun PlaceDetailRoute( ) } - when (state.placeDetailModel) { + LaunchedEffect(state.reviewId, userProfile.userId) { + if (state.reviewId !is UiState.Success) return@LaunchedEffect + if (userProfile.userId == -1) return@LaunchedEffect + + val uiState = state.placeDetailModel + if (uiState is UiState.Success) { + tracker.commonEvents.reviewViewed( + reviewTrackingModel = ReviewTrackingModel( + reviewId = (state.reviewId as UiState.Success).data, + authorUserId = userProfile.userId, + placeName = uiState.data.placeName, + category = uiState.data.category.categoryName, + menuCount = uiState.data.menuList.size, + satisfactionScore = uiState.data.value, + reviewLength = uiState.data.description.length, + photoCount = uiState.data.photoUrlList.size, + hasDisappointment = uiState.data.cons.isNotEmpty(), + savedCount = state.addMapCount + ), + isSelfReview = uiState.data.isMine, + isFollowedUserReview = state.isFollowing, + isSavedReview = state.isAddMap + ) + } + } + + when (val uiState = state.placeDetailModel) { is UiState.Empty -> {} is UiState.Loading -> {} is UiState.Failure -> {} is UiState.Success -> { val postId = (state.reviewId as? UiState.Success)?.data ?: return + if (scoopDialogVisibility) { ScoopDialog( onClickPositive = { viewModel.useSpoon(postId) scoopDialogVisibility = false + tracker.reviewDetailEvents.spoonUsed( + reviewTrackingModel = ReviewTrackingModel( + reviewId = (state.reviewId as UiState.Success).data, + authorUserId = userProfile.userId, + placeName = uiState.data.placeName, + category = uiState.data.category.categoryName, + menuCount = uiState.data.menuList.size, + satisfactionScore = uiState.data.value, + reviewLength = uiState.data.description.length, + photoCount = uiState.data.photoUrlList.size, + hasDisappointment = uiState.data.cons.isNotEmpty(), + savedCount = state.addMapCount + ), + isFollowingAuthor = state.isFollowing + ) }, onClickNegative = { scoopDialogVisibility = false @@ -164,6 +210,7 @@ fun PlaceDetailRoute( DropdownOption.EDIT, DropdownOption.DELETE ) + false -> persistentListOf(DropdownOption.REPORT) } Scaffold( @@ -187,6 +234,21 @@ fun PlaceDetailRoute( addMapCount = state.addMapCount, isAddMap = state.isAddMap, onSearchMapClick = { + tracker.reviewDetailEvents.directionClicked( + reviewTrackingModel = ReviewTrackingModel( + reviewId = (state.reviewId as UiState.Success).data, + authorUserId = userProfile.userId, + placeName = uiState.data.placeName, + category = uiState.data.category.categoryName, + menuCount = uiState.data.menuList.size, + satisfactionScore = uiState.data.value, + reviewLength = uiState.data.description.length, + photoCount = uiState.data.photoUrlList.size, + hasDisappointment = uiState.data.cons.isNotEmpty(), + savedCount = state.addMapCount + ), + isFollowingAuthor = state.isFollowing + ) searchPlaceNaverMap( latitude = data.latitude, longitude = data.longitude, @@ -195,8 +257,42 @@ fun PlaceDetailRoute( ) }, isNotMine = !data.isMine, - onAddMapButtonClick = { viewModel.addMyMap(postId) }, - onDeletePinMapButtonClick = { viewModel.deletePinMap(postId) } + onAddMapButtonClick = { + viewModel.addMyMap(postId) + tracker.reviewDetailEvents.placeMapSaved( + reviewTrackingModel = ReviewTrackingModel( + reviewId = (state.reviewId as UiState.Success).data, + authorUserId = userProfile.userId, + placeName = uiState.data.placeName, + category = uiState.data.category.categoryName, + menuCount = uiState.data.menuList.size, + satisfactionScore = uiState.data.value, + reviewLength = uiState.data.description.length, + photoCount = uiState.data.photoUrlList.size, + hasDisappointment = uiState.data.cons.isNotEmpty(), + savedCount = state.addMapCount + ), + isFollowingAuthor = state.isFollowing + ) + }, + onDeletePinMapButtonClick = { + viewModel.deletePinMap(postId) + tracker.reviewDetailEvents.placeMapRemoved( + reviewTrackingModel = ReviewTrackingModel( + reviewId = (state.reviewId as UiState.Success).data, + authorUserId = userProfile.userId, + placeName = uiState.data.placeName, + category = uiState.data.category.categoryName, + menuCount = uiState.data.menuList.size, + satisfactionScore = uiState.data.value, + reviewLength = uiState.data.description.length, + photoCount = uiState.data.photoUrlList.size, + hasDisappointment = uiState.data.cons.isNotEmpty(), + savedCount = state.addMapCount + ), + isFollowingAuthor = state.isFollowing + ) + } ) }, content = { paddingValues -> @@ -224,7 +320,41 @@ fun PlaceDetailRoute( userName = userProfile.userName, userRegion = userProfile.userRegion, isFollowing = state.isFollowing, - onFollowButtonClick = { viewModel.onFollowButtonClick(userProfile.userId, state.isFollowing) }, + onFollowButtonClick = { + viewModel.onFollowButtonClick(userProfile.userId, state.isFollowing) + + if (state.isFollowing) { + tracker.commonEvents.unfollowUserFromReview( + reviewTrackingModel = ReviewTrackingModel( + reviewId = (state.reviewId as UiState.Success).data, + authorUserId = userProfile.userId, + placeName = uiState.data.placeName, + category = uiState.data.category.categoryName, + menuCount = uiState.data.menuList.size, + satisfactionScore = uiState.data.value, + reviewLength = uiState.data.description.length, + photoCount = uiState.data.photoUrlList.size, + hasDisappointment = uiState.data.cons.isNotEmpty(), + savedCount = state.addMapCount + ) + ) + } else { + tracker.commonEvents.followUserFromReview( + reviewTrackingModel = ReviewTrackingModel( + reviewId = (state.reviewId as UiState.Success).data, + authorUserId = userProfile.userId, + placeName = uiState.data.placeName, + category = uiState.data.category.categoryName, + menuCount = uiState.data.menuList.size, + satisfactionScore = uiState.data.value, + reviewLength = uiState.data.description.length, + photoCount = uiState.data.photoUrlList.size, + hasDisappointment = uiState.data.cons.isNotEmpty(), + savedCount = state.addMapCount + ) + ) + } + }, photoUrlList = data.photoUrlList, date = data.createdAt.formatToYearMonthDay(), placeAddress = data.placeAddress, @@ -234,7 +364,27 @@ fun PlaceDetailRoute( isScooped = state.isScooped || data.isMine, dropdownMenuList = dropDownMenuList, onReportButtonClick = { navigateToReport(postId, ReportType.POST) }, - onShowSnackBar = viewModel::showSnackBar + onShowSnackBar = viewModel::showSnackBar, + trackSpoonUseIntent = { + tracker.reviewDetailEvents.spoonUseIntent( + reviewTrackingModel = ReviewTrackingModel( + reviewId = (state.reviewId as UiState.Success).data, + authorUserId = userProfile.userId, + placeName = uiState.data.placeName, + category = uiState.data.category.categoryName, + menuCount = uiState.data.menuList.size, + satisfactionScore = uiState.data.value, + reviewLength = uiState.data.description.length, + photoCount = uiState.data.photoUrlList.size, + hasDisappointment = uiState.data.cons.isNotEmpty(), + savedCount = state.addMapCount + ), + isFollowingAuthor = state.isFollowing + ) + }, + trackSpoonUseFailed = { + tracker.reviewDetailEvents.spoonUseFailed() + } ) } ) @@ -268,7 +418,9 @@ private fun PlaceDetailScreen( isScooped: Boolean, dropdownMenuList: ImmutableList, onReportButtonClick: () -> Unit, - onShowSnackBar: (String) -> Unit + onShowSnackBar: (String) -> Unit, + trackSpoonUseIntent: () -> Unit, + trackSpoonUseFailed: () -> Unit ) { val scrollState = rememberScrollState() Column( @@ -309,9 +461,11 @@ private fun PlaceDetailScreen( DropdownOption.REPORT.name -> { onReportButtonClick() } + DropdownOption.EDIT.name -> { onEditReviewClick() } + DropdownOption.DELETE.name -> { onDeleteReviewClick() } @@ -362,8 +516,10 @@ private fun PlaceDetailScreen( onScoopButtonClick = { if (spoonAmount > 0) { onScoopButtonClick() + trackSpoonUseIntent() } else { onShowSnackBar("남은 스푼이 없어요 ㅠ.ㅠ") + trackSpoonUseFailed() } }, isBlurred = !isScooped diff --git a/app/src/main/java/com/spoony/spoony/presentation/placeDetail/model/PlaceDetailModel.kt b/app/src/main/java/com/spoony/spoony/presentation/placeDetail/model/PlaceDetailModel.kt index 52699ac5e..10d833a8a 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/placeDetail/model/PlaceDetailModel.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/placeDetail/model/PlaceDetailModel.kt @@ -1,6 +1,8 @@ package com.spoony.spoony.presentation.placeDetail.model import com.spoony.spoony.domain.entity.PlaceReviewEntity +import com.spoony.spoony.presentation.gourmet.map.model.CategoryModel +import com.spoony.spoony.presentation.gourmet.map.model.toModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -16,7 +18,8 @@ data class PlaceDetailModel( val placeAddress: String, val latitude: Double, val longitude: Double, - val isMine: Boolean + val isMine: Boolean, + val category: CategoryModel ) fun PlaceReviewEntity.toModel(): PlaceDetailModel = PlaceDetailModel( @@ -30,5 +33,6 @@ fun PlaceReviewEntity.toModel(): PlaceDetailModel = PlaceDetailModel( placeAddress = this.placeAddress ?: "", latitude = this.latitude ?: 0.0, longitude = this.longitude ?: 0.0, - isMine = this.isMine ?: false + isMine = this.isMine ?: false, + category = this.category?.toModel() ?: CategoryModel() ) diff --git a/app/src/main/java/com/spoony/spoony/presentation/profileedit/ProfileEditScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/profileedit/ProfileEditScreen.kt index 38b9cfc39..00e40959c 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/profileedit/ProfileEditScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/profileedit/ProfileEditScreen.kt @@ -36,6 +36,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle import com.spoony.spoony.R +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.bottomsheet.SpoonyDatePickerBottomSheet import com.spoony.spoony.core.designsystem.component.bottomsheet.SpoonyRegionBottomSheet import com.spoony.spoony.core.designsystem.component.button.RegionSelectButton @@ -61,6 +62,8 @@ fun ProfileEditScreen( modifier: Modifier = Modifier, viewModel: ProfileEditViewModel = hiltViewModel() ) { + val tracker = LocalTracker.current + val profileEditModel by viewModel.profileEditModel.collectAsStateWithLifecycle() val nicknameState by viewModel.nicknameState.collectAsStateWithLifecycle() val saveButtonEnabled by viewModel.saveButtonEnabled.collectAsStateWithLifecycle() @@ -125,7 +128,10 @@ fun ProfileEditScreen( ) Icon( imageVector = ImageVector.vectorResource(id = R.drawable.ic_question_24), - modifier = Modifier.noRippleClickable { isImageBottomSheetVisible = true }, + modifier = Modifier.noRippleClickable { + isImageBottomSheetVisible = true + tracker.mypageEvents.spoonCharacterViewed() + }, tint = Color.Unspecified, contentDescription = null ) @@ -197,7 +203,12 @@ fun ProfileEditScreen( SaveButton( enabled = saveButtonEnabled, - onClick = viewModel::updateProfileInfo, + onClick = { + viewModel.updateProfileInfo() + tracker.mypageEvents.profileUpdated( + fieldsUpdated = viewModel.fieldsUpdated + ) + }, modifier = Modifier.padding(horizontal = 20.dp) ) diff --git a/app/src/main/java/com/spoony/spoony/presentation/profileedit/ProfileEditViewModel.kt b/app/src/main/java/com/spoony/spoony/presentation/profileedit/ProfileEditViewModel.kt index 06fef5864..60618ff22 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/profileedit/ProfileEditViewModel.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/profileedit/ProfileEditViewModel.kt @@ -46,6 +46,10 @@ class ProfileEditViewModel @Inject constructor( val sideEffect: SharedFlow get() = _sideEffect.asSharedFlow() + private val _fieldsUpdated: MutableSet = mutableSetOf() + val fieldsUpdated: List + get() = _fieldsUpdated.toList() + init { loadProfileEditData() } @@ -79,6 +83,7 @@ class ProfileEditViewModel @Inject constructor( fun updateNickname(nickname: String) { _profileEditModel.update { it.copy(userName = nickname) } updateSaveButtonState() + _fieldsUpdated.add("nickname") } fun updateNicknameState(state: NicknameTextFieldState) { @@ -129,6 +134,7 @@ class ProfileEditViewModel @Inject constructor( _profileEditModel.update { it.copy(introduction = introduction.takeIf { it.isNotBlank() }) } + _fieldsUpdated.add("bio") } fun selectImageLevel(level: Int) { @@ -142,6 +148,7 @@ class ProfileEditViewModel @Inject constructor( profileImages = updatedImages ) } + _fieldsUpdated.add("profile_image") } fun selectDate(year: String, month: String, day: String) { @@ -153,6 +160,7 @@ class ProfileEditViewModel @Inject constructor( isBirthSelected = true ) } + _fieldsUpdated.add("birthdate") } fun selectRegion(regionId: Int, regionName: String) { @@ -163,6 +171,7 @@ class ProfileEditViewModel @Inject constructor( isRegionSelected = true ) } + _fieldsUpdated.add("active_region") } fun updateProfileInfo() { diff --git a/app/src/main/java/com/spoony/spoony/presentation/register/RegisterEndScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/register/RegisterEndScreen.kt index ea6c6c5d3..38f687238 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/register/RegisterEndScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/register/RegisterEndScreen.kt @@ -24,6 +24,8 @@ import com.airbnb.lottie.compose.LottieCompositionSpec import com.airbnb.lottie.compose.LottieConstants import com.airbnb.lottie.compose.rememberLottieComposition import com.spoony.spoony.R +import com.spoony.spoony.core.analytics.events.LocalTracker +import com.spoony.spoony.core.analytics.model.ReviewTrackingModel import com.spoony.spoony.core.designsystem.component.dialog.SingleButtonDialog import com.spoony.spoony.core.designsystem.component.textfield.SpoonyLargeTextField import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme @@ -48,6 +50,8 @@ fun RegisterEndRoute( viewModel: RegisterViewModel, modifier: Modifier = Modifier ) { + val tracker = LocalTracker.current + val state by viewModel.state.collectAsStateWithLifecycle() val registerType = viewModel.registerType @@ -66,9 +70,33 @@ fun RegisterEndRoute( onDetailReviewChange = viewModel::updateDetailReview, onPhotosSelected = viewModel::updatePhotos, onOptionalReviewChange = viewModel::updateOptionalReview, - onRegisterPost = viewModel::registerPost, + onRegisterPost = { + viewModel.registerPost(it) + + tracker.registerEvents.review2Completed( + reviewLength = state.detailReview.length, + photoCount = state.selectedPhotos.size, + hasDisappointment = state.optionalReview.isNotEmpty() + ) + }, onRegisterComplete = onRegisterComplete, - onEditComplete = onEditComplete, + onEditComplete = { postId -> + onEditComplete(postId) + tracker.commonEvents.reviewEdited( + reviewTrackingModel = ReviewTrackingModel( + reviewId = postId, + authorUserId = state.userId, + placeName = state.selectedPlace.placeName, + category = state.selectedCategory.categoryName, + menuCount = state.menuList.size, + satisfactionScore = state.userSatisfactionValue.toDouble(), + reviewLength = state.detailReview.length, + photoCount = state.selectedPhotos.size, + hasDisappointment = state.optionalReview.isNotEmpty(), + savedCount = state.addMapCount + ) + ) + }, postId = viewModel.postId, modifier = modifier ) diff --git a/app/src/main/java/com/spoony/spoony/presentation/register/RegisterScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/register/RegisterScreen.kt index 56ddc6977..d376e30d6 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/register/RegisterScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/register/RegisterScreen.kt @@ -23,12 +23,14 @@ import androidx.lifecycle.flowWithLifecycle import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.topappbar.TitleTopAppBar import com.spoony.spoony.core.designsystem.event.LocalSnackBarTrigger import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme import com.spoony.spoony.core.util.extension.noRippleClickable import com.spoony.spoony.presentation.register.component.TopLinearProgressBar import com.spoony.spoony.presentation.register.model.RegisterState +import com.spoony.spoony.presentation.register.model.RegisterType import com.spoony.spoony.presentation.register.navigation.RegisterRoute import com.spoony.spoony.presentation.register.navigation.registerGraph @@ -45,6 +47,7 @@ fun RegisterRoute( val navController = rememberNavController() val showSnackBar = LocalSnackBarTrigger.current val lifecycleOwner = LocalLifecycleOwner.current + val tracker = LocalTracker.current LaunchedEffect(viewModel.sideEffect, lifecycleOwner) { viewModel.sideEffect.flowWithLifecycle(lifecycleOwner.lifecycle).collect { effect -> @@ -62,6 +65,9 @@ fun RegisterRoute( LaunchedEffect(Unit) { viewModel.loadState() + if (viewModel.registerType == RegisterType.CREATE) { + tracker.commonEvents.tabEntered("upload") + } } RegisterScreen( diff --git a/app/src/main/java/com/spoony/spoony/presentation/register/RegisterStartScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/register/RegisterStartScreen.kt index 4d3279fd0..217770170 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/register/RegisterStartScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/register/RegisterStartScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.chip.IconChip import com.spoony.spoony.core.designsystem.component.slider.SpoonySlider import com.spoony.spoony.core.designsystem.component.textfield.SpoonyIconButtonTextField @@ -62,6 +63,8 @@ fun RegisterStartRoute( viewModel: RegisterViewModel, modifier: Modifier = Modifier ) { + val tracker = LocalTracker.current + val state by viewModel.state.collectAsStateWithLifecycle() val registerType = viewModel.registerType @@ -80,7 +83,14 @@ fun RegisterStartRoute( RegisterStartScreen( state = state, isNextButtonEnabled = isNextButtonEnabled, - onNextClick = onNextClick, + onNextClick = { + tracker.registerEvents.review1Completed( + placeName = state.selectedPlace.placeName, + category = state.selectedCategory.categoryName, + menuCount = state.menuList.size + ) + onNextClick() + }, onSearchQueryChange = viewModel::updateSearchQuery, onSearchAction = viewModel::searchPlace, onPlaceSelect = viewModel::selectPlace, diff --git a/app/src/main/java/com/spoony/spoony/presentation/register/model/PlaceReviewModel.kt b/app/src/main/java/com/spoony/spoony/presentation/register/model/PlaceReviewModel.kt index b103cf4b8..8dcf67205 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/register/model/PlaceReviewModel.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/register/model/PlaceReviewModel.kt @@ -19,7 +19,8 @@ data class PlaceReviewModel( val placeAddress: String, val latitude: Double, val longitude: Double, - val category: CategoryState + val category: CategoryState, + val addMapCount: Int ) fun PlaceReviewEntity.toModel(): PlaceReviewModel = @@ -36,7 +37,8 @@ fun PlaceReviewEntity.toModel(): PlaceReviewModel = placeAddress = placeAddress ?: "", latitude = latitude ?: 0.0, longitude = longitude ?: 0.0, - category = category?.toModel() ?: CategoryState(0, "", "", "") + category = category?.toModel() ?: CategoryState(0, "", "", ""), + addMapCount = addMapCount ?: -1 ) fun PlaceReviewModel.toRegisterState(currentState: RegisterState): RegisterState = @@ -56,5 +58,7 @@ fun PlaceReviewModel.toRegisterState(currentState: RegisterState): RegisterState originalPhotoUrls = photoUrls, selectedPhotos = photoUrls.map { url -> SelectedPhoto(uri = url.toUri(), isFromServer = true) - }.toImmutableList() + }.toImmutableList(), + userId = this.userId, + addMapCount = this.addMapCount ) diff --git a/app/src/main/java/com/spoony/spoony/presentation/register/model/RegisterState.kt b/app/src/main/java/com/spoony/spoony/presentation/register/model/RegisterState.kt index 6f43d33c0..f2230ab68 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/register/model/RegisterState.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/register/model/RegisterState.kt @@ -24,7 +24,10 @@ data class RegisterState( val currentStep: Float = 1f, val isLoading: Boolean = false, val isSubmitting: Boolean = false, - val error: String? = null + val error: String? = null, + + val userId: Int = -1, + val addMapCount: Int = -1 ) { companion object { const val DEFAULT = 50f diff --git a/app/src/main/java/com/spoony/spoony/presentation/setting/account/AccountDeleteScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/setting/account/AccountDeleteScreen.kt index 7f9fa31d5..1109f15d4 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/setting/account/AccountDeleteScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/setting/account/AccountDeleteScreen.kt @@ -29,6 +29,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.flowWithLifecycle import com.jakewharton.processphoenix.ProcessPhoenix +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.button.SpoonyButton import com.spoony.spoony.core.designsystem.component.topappbar.TitleTopAppBar import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme @@ -46,9 +47,11 @@ fun AccountDeleteScreen( ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val tracker = LocalTracker.current LaunchedEffect(viewModel.restartTrigger, lifecycleOwner) { viewModel.restartTrigger.flowWithLifecycle(lifecycleOwner.lifecycle).collect { effect -> + tracker.userProperties.resetUserProfile() ProcessPhoenix.triggerRebirth(context) } } diff --git a/app/src/main/java/com/spoony/spoony/presentation/setting/account/AccountManagementScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/setting/account/AccountManagementScreen.kt index 16ec04161..45ca3a464 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/setting/account/AccountManagementScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/setting/account/AccountManagementScreen.kt @@ -23,8 +23,8 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.flowWithLifecycle -import androidx.lifecycle.viewmodel.compose.viewModel import com.jakewharton.processphoenix.ProcessPhoenix +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.dialog.TwoButtonDialog import com.spoony.spoony.core.designsystem.component.topappbar.TitleTopAppBar import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme @@ -40,9 +40,11 @@ internal fun AccountManagementScreen( ) { val context = LocalContext.current val lifecycleOwner = LocalLifecycleOwner.current + val tracker = LocalTracker.current LaunchedEffect(viewModel.restartTrigger, lifecycleOwner) { viewModel.restartTrigger.flowWithLifecycle(lifecycleOwner.lifecycle).collect { effect -> + tracker.userProperties.resetUserProfile() ProcessPhoenix.triggerRebirth(context) } } diff --git a/app/src/main/java/com/spoony/spoony/presentation/splash/SplashScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/splash/SplashScreen.kt index c75c6093e..f16ea16b4 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/splash/SplashScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/splash/SplashScreen.kt @@ -10,14 +10,19 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.flowWithLifecycle import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.spoony.spoony.R +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme import com.spoony.spoony.core.designsystem.theme.main400 @@ -28,17 +33,47 @@ fun SplashRoute( viewModel: SplashViewModel = hiltViewModel() ) { val systemUiController = rememberSystemUiController() + val lifecycleOwner = LocalLifecycleOwner.current + val tracker = LocalTracker.current + + val state by viewModel.state.collectAsStateWithLifecycle() LaunchedEffect(Unit) { systemUiController.setNavigationBarColor( color = main400 ) - if (viewModel.hasAccessToken()) { - navigateToMap() - } else { - navigateToSignIn() - } + tracker.analyticsEvents.appOpen() + } + + LaunchedEffect(viewModel.sideEffect, lifecycleOwner) { + viewModel.sideEffect.flowWithLifecycle(lifecycleOwner.lifecycle) + .collect { sideEffect -> + when (sideEffect) { + is SplashSideEffect.NavigateToMap -> { + navigateToMap() + + state?.let { + tracker.userProperties.setUserProfile( + userId = it.userId.toString(), + properties = mapOf( + Pair("login_method", it.platform), + Pair("nickname", it.userName), + Pair("active_region", it.regionName.orEmpty()), + Pair("has_bio", !it.introduction.isNullOrBlank()), + Pair("total_review_count", it.reviewCount), + Pair("follower_count", it.followerCount), + Pair("following_count", it.followingCount), + Pair("signup_date", it.createdAt), + Pair("last_active_date", it.lastEnteredDate.orEmpty()) + ) + ) + } + } + + is SplashSideEffect.NavigateToSignIn -> navigateToSignIn() + } + } } SplashScreen() diff --git a/app/src/main/java/com/spoony/spoony/presentation/splash/SplashSideEffect.kt b/app/src/main/java/com/spoony/spoony/presentation/splash/SplashSideEffect.kt new file mode 100644 index 000000000..85de85879 --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/presentation/splash/SplashSideEffect.kt @@ -0,0 +1,6 @@ +package com.spoony.spoony.presentation.splash + +sealed class SplashSideEffect { + data object NavigateToMap : SplashSideEffect() + data object NavigateToSignIn : SplashSideEffect() +} diff --git a/app/src/main/java/com/spoony/spoony/presentation/splash/SplashViewModel.kt b/app/src/main/java/com/spoony/spoony/presentation/splash/SplashViewModel.kt index 7dbbcf309..e8d036c3b 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/splash/SplashViewModel.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/splash/SplashViewModel.kt @@ -2,20 +2,66 @@ package com.spoony.spoony.presentation.splash import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.spoony.spoony.core.util.extension.onLogFailure +import com.spoony.spoony.domain.repository.SpoonRepository import com.spoony.spoony.domain.repository.TokenRepository +import com.spoony.spoony.domain.repository.UserRepository +import com.spoony.spoony.presentation.splash.model.UserModel +import com.spoony.spoony.presentation.splash.model.toModel import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @HiltViewModel class SplashViewModel @Inject constructor( - private val tokenRepository: TokenRepository + private val tokenRepository: TokenRepository, + private val userRepository: UserRepository, + private val spoonRepository: SpoonRepository ) : ViewModel() { + private var _state: MutableStateFlow = MutableStateFlow(null) + val state: StateFlow + get() = _state.asStateFlow() + + private val _sideEffect: MutableSharedFlow = MutableSharedFlow() + val sideEffect: SharedFlow + get() = _sideEffect.asSharedFlow() + init { viewModelScope.launch { tokenRepository.initCachedAccessToken() + + if (hasAccessToken()) { + getUserInfo() + } else { + _sideEffect.emit(SplashSideEffect.NavigateToSignIn) + } + } + } + + private suspend fun hasAccessToken(): Boolean = tokenRepository.getAccessToken().first().isNotBlank() + + private fun getUserInfo() { + viewModelScope.launch { + val lastEnteredDate = spoonRepository.getSpoonDrawLog().first + + userRepository.getMyInfo() + .onSuccess { response -> + _state.update { + response.toModel(lastEnteredDate) + } + _sideEffect.emit(SplashSideEffect.NavigateToMap) + } + .onLogFailure { + _sideEffect.emit(SplashSideEffect.NavigateToSignIn) + } } } - suspend fun hasAccessToken(): Boolean = tokenRepository.getAccessToken().first().isNotBlank() } diff --git a/app/src/main/java/com/spoony/spoony/presentation/splash/model/UserModel.kt b/app/src/main/java/com/spoony/spoony/presentation/splash/model/UserModel.kt new file mode 100644 index 000000000..8d8a86fe8 --- /dev/null +++ b/app/src/main/java/com/spoony/spoony/presentation/splash/model/UserModel.kt @@ -0,0 +1,31 @@ +package com.spoony.spoony.presentation.splash.model + +import com.spoony.spoony.domain.entity.BasicUserInfoEntity + +data class UserModel( + val userId: Int, + val platform: String, + val userName: String, + val regionName: String?, + val introduction: String?, + val createdAt: String, + val updatedAt: String, + val followerCount: Int, + val followingCount: Int, + val reviewCount: Int, + val lastEnteredDate: String? +) + +internal fun BasicUserInfoEntity.toModel(lastEnteredDate: String?) = UserModel( + userId = this.userId, + platform = this.platform, + userName = this.userName, + regionName = this.regionName, + introduction = this.introduction, + createdAt = this.createdAt, + updatedAt = this.updatedAt, + followerCount = this.followerCount, + followingCount = this.followingCount, + reviewCount = this.reviewCount, + lastEnteredDate = lastEnteredDate +) diff --git a/app/src/main/java/com/spoony/spoony/presentation/userpage/component/UserScreen.kt b/app/src/main/java/com/spoony/spoony/presentation/userpage/component/UserScreen.kt index 00b7ee5c2..009514fa6 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/userpage/component/UserScreen.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/userpage/component/UserScreen.kt @@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.items import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -27,6 +28,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.spoony.spoony.R +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.component.card.ReviewCard import com.spoony.spoony.core.designsystem.component.dialog.TwoButtonDialog import com.spoony.spoony.core.designsystem.component.screen.EmptyContent @@ -55,10 +57,22 @@ fun UserPageScreen( paddingValues: PaddingValues, modifier: Modifier = Modifier ) { + val tracker = LocalTracker.current + var isReviewDeleteDialogVisible by remember { mutableStateOf(false) } var isUserBlockDialogVisible by remember { mutableStateOf(false) } val topBarMenuItemList = persistentListOf("차단하기", "신고하기") + LaunchedEffect(state.profileId) { + if (state.profileId != 0) { + tracker.commonEvents.profileViewed( + profileUserId = state.profileId, + isSelfProfile = state.userType == UserType.MY_PAGE, + isFollowingProfileUser = state.profile.isFollowing + ) + } + } + LazyColumn( modifier = modifier .fillMaxSize() diff --git a/app/src/main/java/com/spoony/spoony/presentation/userpage/mypage/MyPageRoute.kt b/app/src/main/java/com/spoony/spoony/presentation/userpage/mypage/MyPageRoute.kt index 5cb6d2c80..77c6fc6e3 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/userpage/mypage/MyPageRoute.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/userpage/mypage/MyPageRoute.kt @@ -10,6 +10,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.event.LocalSnackBarTrigger import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme import com.spoony.spoony.presentation.follow.model.FollowType @@ -35,6 +36,7 @@ fun MyPageRoute( val userPageState by viewModel.state.collectAsStateWithLifecycle() val showSnackBar = LocalSnackBarTrigger.current val lifecycleOwner = LocalLifecycleOwner.current + val tracker = LocalTracker.current LaunchedEffect(Unit) { viewModel.getUserProfile() @@ -47,6 +49,7 @@ fun MyPageRoute( is MyPageSideEffect.ShowSnackbar -> { showSnackBar(effect.message) } + is MyPageSideEffect.ShowError -> { showSnackBar(effect.errorType.description) } @@ -54,6 +57,12 @@ fun MyPageRoute( } } + LaunchedEffect(userPageState.userType) { + if (userPageState.userType == UserType.MY_PAGE) { + tracker.commonEvents.tabEntered("mypage") + } + } + val userPageEvents = UserPageEvents( onSettingClick = navigateToSettings, onMainButtonClick = navigateToProfileEdit, diff --git a/app/src/main/java/com/spoony/spoony/presentation/userpage/otherpage/OtherPageRoute.kt b/app/src/main/java/com/spoony/spoony/presentation/userpage/otherpage/OtherPageRoute.kt index d2facb2a1..ddfeb13d7 100644 --- a/app/src/main/java/com/spoony/spoony/presentation/userpage/otherpage/OtherPageRoute.kt +++ b/app/src/main/java/com/spoony/spoony/presentation/userpage/otherpage/OtherPageRoute.kt @@ -11,6 +11,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.flowWithLifecycle +import com.spoony.spoony.core.analytics.events.LocalTracker import com.spoony.spoony.core.designsystem.event.LocalSnackBarTrigger import com.spoony.spoony.core.designsystem.theme.SpoonyAndroidTheme import com.spoony.spoony.presentation.follow.model.FollowType @@ -35,6 +36,7 @@ fun OtherPageRoute( val userPageState by viewModel.state.collectAsStateWithLifecycle() val showSnackBar = LocalSnackBarTrigger.current val lifecycleOwner = LocalLifecycleOwner.current + val tracker = LocalTracker.current BackHandler { if (userPageState.isBlocked) { @@ -54,6 +56,7 @@ fun OtherPageRoute( is OtherPageSideEffect.ShowSnackbar -> { showSnackBar(effect.message) } + is OtherPageSideEffect.ShowErrorSnackbar -> { showSnackBar(effect.errorType.description) } @@ -73,9 +76,30 @@ fun OtherPageRoute( onReviewClick = navigateToReviewDetail, onReportUserClick = navigateToUserReport, onUserBlockClick = viewModel::blockUser, - onMainButtonClick = viewModel::toggleFollow, + onMainButtonClick = { + if (userPageState.isFollowing) { + tracker.commonEvents.unfollowUser( + unfollowedUserId = userPageState.profile.profileId, + entryPoint = "user_profile" + ) + } else { + tracker.commonEvents.followUser( + followedUserId = userPageState.profile.profileId, + entryPoint = "user_profile" + ) + } + + viewModel.toggleFollow() + }, onReportReviewClick = navigateToReviewReport, - onCheckBoxClick = viewModel::toggleLocalReviewOnly + onCheckBoxClick = { + tracker.commonEvents.filterApplied( + pageApplied = "user_profile", + localReviewFilter = userPageState.isLocalReviewOnly + ) + + viewModel.toggleLocalReviewOnly() + } ) UserPageScreen( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ebc83d588..b3c4a7326 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,6 +74,9 @@ playServicesMaps = "19.2.0" ## Room room = "2.8.1" +## mixpanel +mixpanel = "8.2.4" + [libraries] # Test junit = { group = "junit", name = "junit", version.ref = "junit" } @@ -156,6 +159,9 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = " firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } +## Mixpanel +mixpanel = { group = "com.mixpanel.android", name = "mixpanel-android", version.ref = "mixpanel" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } @@ -164,7 +170,7 @@ kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", versi hilt = { id = "com.google.dagger.hilt.android", version.ref = "daggerHilt" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ktlint = { id = "org.jlleitschuh.gradle.ktlint", version.ref = "ktlint" } -oss-licenses-plugin = { id = "com.google.android.gms.oss-licenses-plugin"} +oss-licenses-plugin = { id = "com.google.android.gms.oss-licenses-plugin" } # Firebase firebase-crashlytics = { id = "com.google.firebase.crashlytics", version.ref = "firebaseCrashlyticsPlugin" }