Replace all system colors (.secondary, Color(.secondarySystemBackground), etc.) with Theme.textPrimary/textSecondary/textMuted/cardBackground/ surfaceGlow across 13 views. Remove PostHog debug logging. Add debug settings for sample trips and hardcoded group poll preview. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
297 lines
8.7 KiB
Swift
297 lines
8.7 KiB
Swift
//
|
|
// 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_RnF7XWdPeAY1M8ABAK75KlrOGVFfqHtZbkUuZ7oY8Xm"
|
|
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.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 } }
|
|
|
|
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))
|
|
}
|
|
}
|