Files
Reflect/Shared/Analytics.swift
Trey t 5ec2dfa739 Enable anonymous person profiles for accurate unique user tracking
Set personProfiles to .always so PostHog creates anonymous person
profiles for every user, fixing inaccurate unique user counts in
dashboards without requiring identify() calls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 22:29:05 -05:00

652 lines
22 KiB
Swift

//
// 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
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
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 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"
}
}
// 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 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: Navigation
case tabSwitched(tab: String)
case viewHeaderChanged(header: String)
// MARK: Sharing
case shareTemplateViewed(template: String)
// 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 .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])
// 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])
// 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))
}
}