import FirebaseAnalytics enum AnalyticsEvent { // Onboarding static let onboardingCompleted = "onboarding_completed" // Dishes static let dishAdded = "dish_added" static let dishDeleted = "dish_deleted" // Planner static let mealAssigned = "meal_assigned" static let mealCleared = "meal_cleared" static let weekReset = "week_reset" static let weekPlanShared = "week_plan_shared" // Premium static let premiumUpgradeTapped = "premium_upgrade_tapped" static let premiumPurchased = "premium_purchased" static let premiumPurchaseFailed = "premium_purchase_failed" static let premiumRestored = "premium_restored" // Planner static let autoAssignUsed = "auto_assign_used" static let weekCopiedPrevious = "week_copied_previous" // Shopping list (2.0) static let shoppingListOpened = "shopping_list_opened" static let shoppingItemAdded = "shopping_item_added" static let ingredientsGenerated = "ingredients_generated" // Onboarding static let onboardingStepViewed = "onboarding_step_viewed" // Settings static let iCloudSyncToggled = "icloud_sync_toggled" static let notificationsToggled = "notifications_toggled" static let languageChanged = "language_changed" // Stats static let statsViewed = "stats_viewed" // Feedback loop static let weekRated = "week_rated" // Conversion funnel (1.1.5) static let paywallViewed = "paywall_viewed" static let paywallDismissed = "paywall_dismissed" static let dishLimitHit = "dish_limit_hit" static let weekLimitHit = "week_limit_hit" static let freeTrialStarted = "free_trial_started" } enum AnalyticsService { /// UI tests drive the app against the production Firebase project, so every /// run would otherwise land in GA4 as a real user: a full onboarding funnel, /// a paywall view, the lot. It already happened — the step 5/6 events /// recorded under 2.0 were test runs, not users. static let launchArgumentDisablingAnalytics = "UITEST_DISABLE_ANALYTICS" static let isDisabled: Bool = ProcessInfo.processInfo.arguments.contains(launchArgumentDisablingAnalytics) /// Silences Firebase's own automatic events (first_open, session_start, /// screen_view), which no-oping `logEvent` alone would not catch. /// Call once, right after `FirebaseApp.configure()`. /// /// Always sets the flag, never just when disabling: Firebase persists this /// across launches, so a single test run would otherwise leave analytics off /// for good on that install. static func applyCollectionPolicy() { Analytics.setAnalyticsCollectionEnabled(!isDisabled) } static func logEvent(_ name: String, parameters: [String: Any]? = nil) { guard !isDisabled else { return } Analytics.logEvent(name, parameters: parameters) } static func logScreenView(_ screenName: String) { guard !isDisabled else { return } Analytics.logEvent(AnalyticsEventScreenView, parameters: [ AnalyticsParameterScreenName: screenName, AnalyticsParameterScreenClass: screenName ]) } // MARK: - Convenience methods static func logDishAdded(tagCount: Int, source: String = "editor") { logEvent(AnalyticsEvent.dishAdded, parameters: [ "tag_count": tagCount, "source": source ]) } static func logDishDeleted() { logEvent(AnalyticsEvent.dishDeleted) } static func logMealAssigned() { logEvent(AnalyticsEvent.mealAssigned) } static func logMealCleared() { logEvent(AnalyticsEvent.mealCleared) } static func logWeekReset() { logEvent(AnalyticsEvent.weekReset) } static func logWeekPlanShared(format: String) { logEvent(AnalyticsEvent.weekPlanShared, parameters: ["format": format]) } static func logPremiumUpgradeTapped(source: String) { logEvent(AnalyticsEvent.premiumUpgradeTapped, parameters: ["source": source]) } static func logPremiumPurchased(environment: String = "unknown") { logEvent(AnalyticsEvent.premiumPurchased, parameters: ["environment": environment]) } /// Why a tapped upgrade never became a purchase. Without this a user who /// changed their mind and a user whose purchase broke look identical — both /// are just a `premium_upgrade_tapped` with nothing after it. /// /// `reason` is one of: `cancelled`, `pending`, `unverified`, `error`. static func logPremiumPurchaseFailed(reason: String, productId: String, detail: String? = nil) { var parameters: [String: Any] = ["reason": reason, "product_id": productId] if let detail { // GA4 drops string params over 100 chars. parameters["detail"] = String(detail.prefix(100)) } logEvent(AnalyticsEvent.premiumPurchaseFailed, parameters: parameters) } static func logPremiumRestored() { logEvent(AnalyticsEvent.premiumRestored) } static func logOnboardingCompleted(dishCount: Int) { logEvent(AnalyticsEvent.onboardingCompleted, parameters: ["dish_count": dishCount]) } // Human-readable names for each onboarding step, indexed by step number. // Keep in sync with the OnboardingView step order. private static let onboardingStepNames = [ "0_welcome", "1_meal_windows", "2_weekends", "3_calendar", "4_first_dishes", "5_week_ready", "6_paywall" ] static func logOnboardingStepViewed(step: Int) { // GA4 text custom dimensions do not capture numeric params (they show as // "(not set)"). Log the step as a String so the drop-off is queryable. let name = step >= 0 && step < onboardingStepNames.count ? onboardingStepNames[step] : "\(step)" logEvent(AnalyticsEvent.onboardingStepViewed, parameters: [ "step": "\(step)", "step_name": name ]) } static func logAutoAssignUsed(filledSlots: Int, unfilledSlots: Int) { logEvent(AnalyticsEvent.autoAssignUsed, parameters: [ "filled_slots": filledSlots, "unfilled_slots": unfilledSlots ]) } static func logWeekCopiedPrevious(copiedSlots: Int, source: String) { logEvent(AnalyticsEvent.weekCopiedPrevious, parameters: [ "copied_slots": copiedSlots, "source": source ]) } static func logShoppingListOpened(itemCount: Int, missingDishes: Int) { logEvent(AnalyticsEvent.shoppingListOpened, parameters: [ "item_count": itemCount, "missing_dishes": missingDishes ]) } static func logShoppingItemAdded() { logEvent(AnalyticsEvent.shoppingItemAdded) } static func logIngredientsGenerated(lineCount: Int, source: String) { logEvent(AnalyticsEvent.ingredientsGenerated, parameters: [ "line_count": lineCount, "source": source ]) } static func logICloudSyncToggled(enabled: Bool) { logEvent(AnalyticsEvent.iCloudSyncToggled, parameters: ["enabled": enabled]) } static func logNotificationsToggled(enabled: Bool) { logEvent(AnalyticsEvent.notificationsToggled, parameters: ["enabled": enabled]) } static func logStatsViewed() { logEvent(AnalyticsEvent.statsViewed) } static func logWeekRated(rating: Int) { let value = rating == 1 ? "liked" : rating == -1 ? "disliked" : "cleared" logEvent(AnalyticsEvent.weekRated, parameters: ["rating": value]) } static func logPaywallViewed(source: String) { logEvent(AnalyticsEvent.paywallViewed, parameters: ["source": source]) } static func logPaywallDismissed(source: String, secondsOnScreen: Int, didConvert: Bool) { logEvent(AnalyticsEvent.paywallDismissed, parameters: [ "source": source, "seconds_on_screen": secondsOnScreen, "did_convert": didConvert ]) } static func logDishLimitHit() { logEvent(AnalyticsEvent.dishLimitHit) } static func logWeekLimitHit() { logEvent(AnalyticsEvent.weekLimitHit) } static func logFreeTrialStarted(plan: String) { logEvent(AnalyticsEvent.freeTrialStarted, parameters: ["plan": plan]) } }