// // AnalyticsManager.swift // SportsTime // // Singleton analytics manager wrapping PostHog SDK. // All analytics events flow through this single manager. // import Foundation import PostHog import SwiftUI @MainActor final class AnalyticsManager { // MARK: - Singleton static let shared = AnalyticsManager() // MARK: - Configuration private static let apiKey = "phc_SportsTime_production" 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) // Auto-capture config.captureElementInteractions = true config.captureApplicationLifecycleEvents = true config.captureScreenViews = true // 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.debug = true config.flushAt = 1 #endif PostHogSDK.shared.setup(config) isConfigured = true // Register super properties registerSuperProperties() } // MARK: - Super Properties func registerSuperProperties() { 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 isPro = StoreManager.shared.isPro let animationsEnabled = DesignStyleManager.shared.animationsEnabled // Load selected sports from UserDefaults let selectedSports = UserDefaults.standard.stringArray(forKey: "selectedSports") ?? Sport.supported.map(\.rawValue) // Keep super-property keys aligned with Feels so dashboards can compare apps 1:1. PostHogSDK.shared.register([ "app_version": version, "build_number": build, "device_model": device, "os_version": osVersion, "is_pro": isPro, "animations_enabled": animationsEnabled, "selected_sports": selectedSports, "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, ]) } func updateSuperProperties() { registerSuperProperties() } // MARK: - Event Tracking func track(_ event: AnalyticsEvent) { guard isConfigured else { return } PostHogSDK.shared.capture(event.name, properties: event.properties) } // MARK: - Screen Tracking (manual supplement to auto-capture) func trackScreen(_ screenName: String, properties: [String: Any]? = nil) { guard isConfigured else { return } var props: [String: Any] = ["screen_name": screenName] if let properties { props.merge(properties) { _, new in new } } #if DEBUG print("[Analytics] screen_viewed: \(screenName)") #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: - SwiftUI Screen Tracking Modifier struct ScreenTrackingModifier: ViewModifier { let screenName: String let properties: [String: Any]? func body(content: Content) -> some View { content.onAppear { AnalyticsManager.shared.trackScreen(screenName, properties: properties) } } } extension View { func trackScreen(_ screenName: String, properties: [String: Any]? = nil) -> some View { modifier(ScreenTrackingModifier(screenName: screenName, properties: properties)) } }