Files
Sportstime/SportsTime/Core/Analytics/AnalyticsManager.swift
Trey t d63d311cab feat: add WCAG AA accessibility app-wide, fix CloudKit container config, remove debug logs
- Add VoiceOver labels, hints, and element grouping across all 60+ views
- Add Reduce Motion support (Theme.Animation.prefersReducedMotion) to all animations
- Replace fixed font sizes with semantic Dynamic Type styles
- Hide decorative elements from VoiceOver with .accessibilityHidden(true)
- Add .minimumHitTarget() modifier ensuring 44pt touch targets
- Add AccessibilityAnnouncer utility for VoiceOver announcements
- Improve color contrast values in Theme.swift for WCAG AA compliance
- Extract CloudKitContainerConfig for explicit container identity
- Remove PostHog debug console log from AnalyticsManager

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:27:23 -06:00

298 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.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 } }
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))
}
}