// // Analytics.swift // Reflect // // Singleton analytics manager wrapping PostHog SDK. // All analytics events flow through this single manager. // import Foundation import PostHog import UIKit // MARK: - Analytics Manager @MainActor final class AnalyticsManager { // MARK: - Singleton static let shared = AnalyticsManager() // MARK: - Configuration private static let apiKey = "phc_3GsB3oqNft8Ykg2bJfE9MaJktzLAwr2EPMXQgwEFzAs" private static let host = "https://analytics.88oakapps.com" private static let optOutKey = "analyticsOptedOut" private static let sessionReplayKey = "analytics_session_replay_enabled" private static let iso8601Formatter = ISO8601DateFormatter() // MARK: - State var isOptedOut: Bool { UserDefaults.standard.bool(forKey: Self.optOutKey) } var sessionReplayEnabled: Bool { get { if UserDefaults.standard.object(forKey: Self.sessionReplayKey) == nil { return true } return UserDefaults.standard.bool(forKey: Self.sessionReplayKey) } set { UserDefaults.standard.set(newValue, forKey: Self.sessionReplayKey) if newValue { PostHogSDK.shared.startSessionRecording() } else { PostHogSDK.shared.stopSessionRecording() } } } private var isConfigured = false // MARK: - Initialization private init() {} // MARK: - Setup func configure() { guard !isConfigured else { return } let config = PostHogConfig(apiKey: Self.apiKey, host: Self.host) // Person profiles — .always creates anonymous person profiles for every user, // enabling accurate unique user counts without requiring identify() calls config.personProfiles = .always // Auto-capture — disabled for SwiftUI (PostHog docs recommend manual tracking) // captureElementInteractions: UIKit-only, generates noisy $autocapture events // captureScreenViews: produces meaningless SwiftUI internal names, duplicates manual trackScreen() config.captureElementInteractions = false config.captureApplicationLifecycleEvents = true config.captureScreenViews = false // Session replay config.sessionReplay = sessionReplayEnabled config.sessionReplayConfig.maskAllTextInputs = true config.sessionReplayConfig.maskAllImages = false config.sessionReplayConfig.captureNetworkTelemetry = true config.sessionReplayConfig.screenshotMode = true // Required for SwiftUI // Respect user opt-out preference if isOptedOut { config.optOut = true } #if DEBUG config.optOut = true // Prevent debug/test data from polluting production analytics #endif PostHogSDK.shared.setup(config) isConfigured = true // Register super properties updateSuperProperties() } // MARK: - Super Properties (attached to every event) func updateSuperProperties() { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown" let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown" let device = UIDevice.current.model let osVersion = UIDevice.current.systemVersion let defaults = GroupUserDefaults.groupDefaults var props: [String: Any] = [ "app_version": version, "build_number": build, "device_model": device, "os_version": osVersion, "is_pro": IAPManager.shared.isSubscribed, "animations_enabled": !UIAccessibility.isReduceMotionEnabled, "selected_sports": [String](), "theme": "n/a", "icon_pack": "n/a", "voting_layout": "n/a", "day_view_style": "n/a", "mood_shape": "n/a", "personality_pack": "n/a", "privacy_lock_enabled": false, "healthkit_enabled": false, "days_filter_count": 0, "days_filter_all": false, ] // Theme if let themeRaw = defaults.string(forKey: UserDefaultsStore.Keys.theme.rawValue) { props["theme"] = themeRaw } // Icon pack if let iconPackRaw = defaults.object(forKey: UserDefaultsStore.Keys.moodImages.rawValue) as? Int, let iconPack = MoodImages(rawValue: iconPackRaw) { props["icon_pack"] = String(describing: iconPack) } // Voting layout if let layoutRaw = defaults.object(forKey: UserDefaultsStore.Keys.votingLayoutStyle.rawValue) as? Int, let layout = VotingLayoutStyle(rawValue: layoutRaw) { props["voting_layout"] = layout.displayName } // Day view style if let styleRaw = defaults.object(forKey: UserDefaultsStore.Keys.dayViewStyle.rawValue) as? Int, let style = DayViewStyle(rawValue: styleRaw) { props["day_view_style"] = style.displayName } // Shape if let shapeRaw = defaults.object(forKey: UserDefaultsStore.Keys.shape.rawValue) as? Int, let shape = BGShape(rawValue: shapeRaw) { props["mood_shape"] = String(describing: shape) } // Personality pack if let packRaw = defaults.object(forKey: UserDefaultsStore.Keys.personalityPack.rawValue) as? Int, let pack = PersonalityPack(rawValue: packRaw) { props["personality_pack"] = pack.title() } // Privacy lock props["privacy_lock_enabled"] = defaults.bool(forKey: UserDefaultsStore.Keys.privacyLockEnabled.rawValue) // HealthKit props["healthkit_enabled"] = defaults.bool(forKey: UserDefaultsStore.Keys.healthKitEnabled.rawValue) // Days filter let daysFilter = UserDefaultsStore.getDaysFilter() props["days_filter_count"] = daysFilter.count props["days_filter_all"] = daysFilter.count == 7 PostHogSDK.shared.register(props) } func registerSuperProperties() { updateSuperProperties() } // MARK: - Event Tracking func track(_ event: Event) { guard isConfigured else { return } let (name, properties) = event.payload #if DEBUG print("[Analytics] \(name)", properties ?? [:]) #endif PostHogSDK.shared.capture(name, properties: properties) } // MARK: - Screen Tracking (manual supplement to auto-capture) func trackScreen(_ screen: Screen, properties: [String: Any]? = nil) { guard isConfigured else { return } var props: [String: Any] = ["screen_name": screen.rawValue] if let properties { props.merge(properties) { _, new in new } } #if DEBUG print("[Analytics] screen_viewed: \(screen.rawValue)") #endif PostHogSDK.shared.capture("screen_viewed", properties: props) } // MARK: - Subscription Funnel func trackPaywallViewed(source: String) { guard isConfigured else { return } PostHogSDK.shared.capture("paywall_viewed", properties: [ "source": source ]) } func trackPurchaseStarted(productId: String, source: String) { guard isConfigured else { return } PostHogSDK.shared.capture("purchase_started", properties: [ "product_id": productId, "source": source ]) } func trackPurchaseCompleted(productId: String, source: String) { guard isConfigured else { return } PostHogSDK.shared.capture("purchase_completed", properties: [ "product_id": productId, "source": source ]) } func trackPurchaseFailed(productId: String?, source: String, error: String) { guard isConfigured else { return } var props: [String: Any] = [ "source": source, "error": error ] if let productId { props["product_id"] = productId } PostHogSDK.shared.capture("purchase_failed", properties: props) } func trackPurchaseRestored(source: String) { guard isConfigured else { return } PostHogSDK.shared.capture("purchase_restored", properties: [ "source": source ]) } // MARK: - Opt In / Opt Out func optIn() { UserDefaults.standard.set(false, forKey: Self.optOutKey) if isConfigured { PostHogSDK.shared.optIn() PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": true]) } } func optOut() { if isConfigured { PostHogSDK.shared.capture("analytics_toggled", properties: ["enabled": false]) } UserDefaults.standard.set(true, forKey: Self.optOutKey) if isConfigured { PostHogSDK.shared.optOut() } } // MARK: - Person Properties (subscription segmentation) func updateSubscriptionStatus(_ status: String, type: String) { guard isConfigured else { return } PostHogSDK.shared.capture("$set", properties: [ "$set": [ "subscription_status": status, "subscription_type": type ] ]) } func trackSubscriptionStatusObserved( status: String, type: String, source: String, isSubscribed: Bool, hasFullAccess: Bool, productId: String?, willAutoRenew: Bool?, isInGracePeriod: Bool?, trialDaysRemaining: Int?, expirationDate: Date? ) { guard isConfigured else { return } var props: [String: Any] = [ "status": status, "type": type, "source": source, "is_subscribed": isSubscribed, "has_full_access": hasFullAccess ] if let productId { props["product_id"] = productId } if let willAutoRenew { props["will_auto_renew"] = willAutoRenew } if let isInGracePeriod { props["is_in_grace_period"] = isInGracePeriod } if let trialDaysRemaining { props["trial_days_remaining"] = trialDaysRemaining } if let expirationDate { props["expiration_date"] = Self.iso8601Formatter.string(from: expirationDate) } PostHogSDK.shared.capture("subscription_status_changed", properties: props) updateSubscriptionStatus(status, type: type) } // MARK: - Lifecycle func flush() { guard isConfigured else { return } PostHogSDK.shared.flush() } func reset() { guard isConfigured else { return } PostHogSDK.shared.reset() } } // MARK: - Screen Names extension AnalyticsManager { enum Screen: String { case day = "day" case month = "month" case monthDetail = "month_detail" case year = "year" case insights = "insights" case settings = "settings" case customize = "customize" case onboarding = "onboarding" case onboardingSubscription = "onboarding_subscription" case paywall = "paywall" case entryDetail = "entry_detail" case noteEditor = "note_editor" case guidedReflection = "guided_reflection" case lockScreen = "lock_screen" case sharing = "sharing" case themePicker = "theme_picker" case iconPackPicker = "icon_pack_picker" case votingLayoutPicker = "voting_layout_picker" case dayStylePicker = "day_style_picker" case shapePicker = "shape_picker" case personalityPackPicker = "personality_pack_picker" case appThemePicker = "app_theme_picker" case dayFilterPicker = "day_filter_picker" case iconPicker = "icon_picker" case widgetCreator = "widget_creator" case widgetPicker = "widget_picker" case privacy = "privacy" case eula = "eula" case specialThanks = "special_thanks" case reminderTimePicker = "reminder_time_picker" case exportView = "export_view" case reports = "reports" } } // MARK: - Event Definitions extension AnalyticsManager { enum Event { // MARK: Mood case moodLogged(mood: Int, entryType: String) case moodUpdated(mood: Int) case noteUpdated(characterCount: Int) case photoAdded case photoDeleted case reflectionStarted case reflectionCompleted(answeredCount: Int) case missingEntriesFilled(count: Int) case entryDeleted(mood: Int) case allDataCleared case duplicatesRemoved(count: Int) // MARK: Customization case themeChanged(themeId: String) case themeApplied(themeName: String) case iconPackChanged(packId: Int) case votingLayoutChanged(layout: String) case dayViewStyleChanged(style: String) case moodShapeChanged(shapeId: Int) case personalityPackChanged(packTitle: String) case appIconChanged(iconTitle: String) case celebrationAnimationChanged(animation: String) // MARK: Widget case widgetViewed case widgetCreateTapped case widgetCreated case widgetUsed case widgetDeleted case widgetShuffled case widgetRandomized case widgetEyeUpdated(style: String) case widgetMouthUpdated(style: String) case widgetBackgroundUpdated(style: String) case widgetColorUpdated(part: String) // MARK: Notifications case notificationEnabled case notificationDisabled case reminderTimeUpdated case reminderTimeTapped // MARK: Onboarding case onboardingCompleted(dayId: String?) case onboardingSubscribeTapped case onboardingSkipped // MARK: Subscription case paywallSubscribeTapped(source: String) // MARK: Settings case settingsClosed case deleteToggleChanged(enabled: Bool) case hapticFeedbackToggled(enabled: Bool) case exportTapped case dataExported(format: String, count: Int) case importTapped case importSucceeded case importFailed(error: String?) case onboardingReshown // MARK: Privacy & Security case privacyLockEnabled case privacyLockDisabled case privacyLockEnableFailed case appLocked case biometricUnlockSuccess case biometricUnlockFailed(error: String) // MARK: HealthKit case healthKitAuthorized case healthKitAuthFailed(error: String) case healthKitEnableFailed case healthKitDisabled case healthKitNotAuthorized case healthKitSyncCompleted(total: Int, success: Int, failed: Int) // MARK: Weather case weatherToggled(enabled: Bool) case weatherFetched case weatherFetchFailed(error: String) // MARK: Navigation case tabSwitched(tab: String) case viewHeaderChanged(header: String) // MARK: Sharing case shareTemplateViewed(template: String) // MARK: Reports case reportGenerated(type: String, entryCount: Int, daySpan: Int) case reportExported(type: String, entryCount: Int) case reportGenerationFailed(error: String) case reportCancelled // MARK: Error case storageFallbackActivated // MARK: Legal case eulaViewed case privacyPolicyViewed case specialThanksViewed var payload: (name: String, properties: [String: Any]?) { switch self { // Mood case .moodLogged(let mood, let entryType): return ("mood_logged", ["mood_value": mood, "entry_type": entryType]) case .moodUpdated(let mood): return ("mood_updated", ["mood_value": mood]) case .noteUpdated(let count): return ("note_updated", ["character_count": count]) case .photoAdded: return ("photo_added", nil) case .photoDeleted: return ("photo_deleted", nil) case .reflectionStarted: return ("reflection_started", nil) case .reflectionCompleted(let count): return ("reflection_completed", ["answered_count": count]) case .missingEntriesFilled(let count): return ("missing_entries_filled", ["count": count]) case .entryDeleted(let mood): return ("entry_deleted", ["mood": mood]) case .allDataCleared: return ("all_data_cleared", nil) case .duplicatesRemoved(let count): return ("duplicates_removed", ["count": count]) // Customization case .themeChanged(let id): return ("theme_changed", ["theme_id": id]) case .themeApplied(let name): return ("theme_applied", ["theme_name": name]) case .iconPackChanged(let id): return ("icon_pack_changed", ["pack_id": id]) case .votingLayoutChanged(let layout): return ("voting_layout_changed", ["layout": layout]) case .dayViewStyleChanged(let style): return ("day_view_style_changed", ["style": style]) case .moodShapeChanged(let id): return ("mood_shape_changed", ["shape_id": id]) case .personalityPackChanged(let title): return ("personality_pack_changed", ["pack_title": title]) case .appIconChanged(let title): return ("app_icon_changed", ["icon_title": title]) case .celebrationAnimationChanged(let animation): return ("celebration_animation_changed", ["animation": animation]) // Widget case .widgetViewed: return ("widget_viewed", nil) case .widgetCreateTapped: return ("widget_create_tapped", nil) case .widgetCreated: return ("widget_created", nil) case .widgetUsed: return ("widget_used", nil) case .widgetDeleted: return ("widget_deleted", nil) case .widgetShuffled: return ("widget_shuffled", nil) case .widgetRandomized: return ("widget_randomized", nil) case .widgetEyeUpdated(let style): return ("widget_eye_updated", ["style": style]) case .widgetMouthUpdated(let style): return ("widget_mouth_updated", ["style": style]) case .widgetBackgroundUpdated(let style): return ("widget_background_updated", ["style": style]) case .widgetColorUpdated(let part): return ("widget_color_updated", ["part": part]) // Notifications case .notificationEnabled: return ("notification_enabled", nil) case .notificationDisabled: return ("notification_disabled", nil) case .reminderTimeUpdated: return ("reminder_time_updated", nil) case .reminderTimeTapped: return ("reminder_time_tapped", nil) // Onboarding case .onboardingCompleted(let dayId): var props: [String: Any] = [:] if let dayId { props["day_id"] = dayId } return ("onboarding_completed", props.isEmpty ? nil : props) case .onboardingSubscribeTapped: return ("onboarding_subscribe_tapped", nil) case .onboardingSkipped: return ("onboarding_skipped", nil) // Subscription case .paywallSubscribeTapped(let source): return ("paywall_subscribe_tapped", ["source": source]) // Settings case .settingsClosed: return ("settings_closed", nil) case .deleteToggleChanged(let enabled): return ("delete_toggle_changed", ["enabled": enabled]) case .hapticFeedbackToggled(let enabled): return ("haptic_feedback_toggled", ["enabled": enabled]) case .exportTapped: return ("export_tapped", nil) case .dataExported(let format, let count): return ("data_exported", ["format": format, "count": count]) case .importTapped: return ("import_tapped", nil) case .importSucceeded: return ("import_succeeded", nil) case .importFailed(let error): return ("import_failed", error != nil ? ["error": error!] : nil) case .onboardingReshown: return ("onboarding_reshown", nil) // Privacy & Security case .privacyLockEnabled: return ("privacy_lock_enabled", nil) case .privacyLockDisabled: return ("privacy_lock_disabled", nil) case .privacyLockEnableFailed: return ("privacy_lock_enable_failed", nil) case .appLocked: return ("app_locked", nil) case .biometricUnlockSuccess: return ("biometric_unlock_success", nil) case .biometricUnlockFailed(let error): return ("biometric_unlock_failed", ["error": error]) // HealthKit case .healthKitAuthorized: return ("healthkit_authorized", nil) case .healthKitAuthFailed(let error): return ("healthkit_auth_failed", ["error": error]) case .healthKitEnableFailed: return ("healthkit_enable_failed", nil) case .healthKitDisabled: return ("healthkit_disabled", nil) case .healthKitNotAuthorized: return ("healthkit_not_authorized", nil) case .healthKitSyncCompleted(let total, let success, let failed): return ("healthkit_sync_completed", ["total": total, "success": success, "failed": failed]) // Weather case .weatherToggled(let enabled): return ("weather_toggled", ["enabled": enabled]) case .weatherFetched: return ("weather_fetched", nil) case .weatherFetchFailed(let error): return ("weather_fetch_failed", ["error": error]) // Navigation case .tabSwitched(let tab): return ("tab_switched", ["tab": tab]) case .viewHeaderChanged(let header): return ("view_header_changed", ["header": header]) // Sharing case .shareTemplateViewed(let template): return ("share_template_viewed", ["template": template]) // Reports case .reportGenerated(let type, let entryCount, let daySpan): return ("report_generated", ["type": type, "entry_count": entryCount, "day_span": daySpan]) case .reportExported(let type, let entryCount): return ("report_exported", ["type": type, "entry_count": entryCount]) case .reportGenerationFailed(let error): return ("report_generation_failed", ["error": error]) case .reportCancelled: return ("report_cancelled", nil) // Error case .storageFallbackActivated: return ("storage_fallback_activated", nil) // Legal case .eulaViewed: return ("eula_viewed", nil) case .privacyPolicyViewed: return ("privacy_policy_viewed", nil) case .specialThanksViewed: return ("special_thanks_viewed", nil) } } } } // MARK: - SwiftUI Screen Tracking Modifier import SwiftUI struct ScreenTrackingModifier: ViewModifier { let screen: AnalyticsManager.Screen let properties: [String: Any]? func body(content: Content) -> some View { content.onAppear { AnalyticsManager.shared.trackScreen(screen, properties: properties) } } } extension View { func trackScreen(_ screen: AnalyticsManager.Screen, properties: [String: Any]? = nil) -> some View { modifier(ScreenTrackingModifier(screen: screen, properties: properties)) } }