diff --git a/Feels.xcodeproj/project.pbxproj b/Feels.xcodeproj/project.pbxproj index 4c382f2..ec406dc 100644 --- a/Feels.xcodeproj/project.pbxproj +++ b/Feels.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 1CD90B56278C7E7A001C4FEA /* FeelsWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 1CD90B45278C7E7A001C4FEA /* FeelsWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; }; 1CD90B6E278C7F8B001C4FEA /* CloudKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B6B278C7F78001C4FEA /* CloudKit.framework */; }; + 1CDE000F2F3BBD26006AE6A1 /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 1CA00002300000000000002A /* PostHog */; }; 1CDEFBBF2F3B8736006AE6A1 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */; }; 1CDEFBC02F3B8736006AE6A1 /* Configuration.storekit in Resources */ = {isa = PBXBuildFile; fileRef = 1CDEFBBE2F3B8736006AE6A1 /* Configuration.storekit */; }; 46F07FA9D330456697C9AC29 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 1CD90B47278C7E7A001C4FEA /* WidgetKit.framework */; }; @@ -96,7 +97,6 @@ membershipExceptions = ( "Color+Codable.swift", "Date+Extensions.swift", - EventLogger.swift, Models/DiamondView.swift, Models/Mood.swift, Models/MoodEntryModel.swift, @@ -147,6 +147,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 1CDE000F2F3BBD26006AE6A1 /* PostHog in Frameworks */, 1C9566442EF8F5F70032E68F /* Algorithms in Frameworks */, 1CD90B6C278C7F78001C4FEA /* CloudKit.framework in Frameworks */, 1CB4D0A028787D8A00902A56 /* StoreKit.framework in Frameworks */, diff --git a/Shared/Analytics.swift b/Shared/Analytics.swift new file mode 100644 index 0000000..2c53733 --- /dev/null +++ b/Shared/Analytics.swift @@ -0,0 +1,625 @@ +// +// Analytics.swift +// Feels +// +// 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) + + // 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) + + // 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) + + // 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 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: 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]) + + // 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]) + + // 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 .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]) + + // 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)) + } +} diff --git a/Shared/AppDelegate.swift b/Shared/AppDelegate.swift index 94cd249..767ef55 100644 --- a/Shared/AppDelegate.swift +++ b/Shared/AppDelegate.swift @@ -28,8 +28,6 @@ class AppDelegate: NSObject, UIApplicationDelegate { UITabBar.appearance().standardAppearance = appearance UITabBar.appearance().scrollEdgeAppearance = appearance - EventLogger.log(event: "app_launced") - return true } diff --git a/Shared/EventLogger.swift b/Shared/EventLogger.swift deleted file mode 100644 index 6ba7748..0000000 --- a/Shared/EventLogger.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// EventLogger.swift -// Feels -// -// Created by Trey Tartt on 3/10/22. -// - -import Foundation -#if canImport(PostHog) -import PostHog -#endif - -class EventLogger { - static func log(event: String, withData data: [String: Any]? = nil) { - #if DEBUG - print("[EventLogger] \(event)", data ?? "") - #endif - #if canImport(PostHog) - PostHogSDK.shared.capture(event, properties: data) - #endif - } -} diff --git a/Shared/FeelsApp.swift b/Shared/FeelsApp.swift index 5ccbecc..31a1416 100644 --- a/Shared/FeelsApp.swift +++ b/Shared/FeelsApp.swift @@ -9,7 +9,6 @@ import SwiftUI import SwiftData import BackgroundTasks import WidgetKit -import PostHog @main struct FeelsApp: App { @@ -24,12 +23,7 @@ struct FeelsApp: App { @State private var showSubscriptionFromWidget = false init() { - // Initialize PostHog analytics - let posthogConfig = PostHogConfig(apiKey: "phc_3GsB3oqNft8Ykg2bJfE9MaJktzLAwr2EPMXQgwEFzAs", host: "https://analytics.88oakapps.com") - #if DEBUG - posthogConfig.debug = true - #endif - PostHogSDK.shared.setup(posthogConfig) + AnalyticsManager.shared.configure() BGTaskScheduler.shared.cancelAllTaskRequests() BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTask.updateDBMissingID, using: nil) { (task) in @@ -59,7 +53,7 @@ struct FeelsApp: App { .environmentObject(authManager) .environmentObject(healthKitManager) .sheet(isPresented: $showSubscriptionFromWidget) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "widget_deeplink") .environmentObject(iapManager) } .onOpenURL { url in @@ -78,6 +72,8 @@ struct FeelsApp: App { if newPhase == .background { BGTask.scheduleBackgroundProcessing() WidgetCenter.shared.reloadAllTimelines() + // Flush pending analytics events + AnalyticsManager.shared.flush() // Lock the app when going to background authManager.lock() } @@ -106,8 +102,8 @@ struct FeelsApp: App { // Reschedule notifications for new title LocalNotification.rescheduleNotifiations() - // Log event - EventLogger.log(event: "app_foregorund") + // Update super properties on foreground + AnalyticsManager.shared.updateSuperProperties() } // Defer Live Activity scheduling (heavy DB operations) @@ -123,36 +119,7 @@ struct FeelsApp: App { // Check subscription status (network call) - throttled Task.detached(priority: .background) { await iapManager.checkSubscriptionStatus() - - // Set PostHog person properties for subscription segmentation - let state = await iapManager.state - let subscriptionStatus: String - let subscriptionType: String - - switch state { - case .subscribed: - subscriptionStatus = "subscribed" - subscriptionType = await iapManager.currentProduct?.id.contains("yearly") == true ? "yearly" : "monthly" - case .inTrial: - subscriptionStatus = "trial" - subscriptionType = "none" - case .trialExpired: - subscriptionStatus = "trial_expired" - subscriptionType = "none" - case .expired: - subscriptionStatus = "expired" - subscriptionType = "none" - case .unknown: - subscriptionStatus = "unknown" - subscriptionType = "none" - } - - PostHogSDK.shared.capture("$set", properties: [ - "$set": [ - "subscription_status": subscriptionStatus, - "subscription_type": subscriptionType - ] - ]) + await iapManager.trackSubscriptionAnalytics(source: "app_foreground") } } } diff --git a/Shared/HealthKitManager.swift b/Shared/HealthKitManager.swift index 401a978..3bd88d7 100644 --- a/Shared/HealthKitManager.swift +++ b/Shared/HealthKitManager.swift @@ -254,11 +254,7 @@ class HealthKitManager: ObservableObject { syncStatus = "Synced \(successCount), failed \(failCount)" } logger.info("HealthKit sync complete: \(successCount) succeeded, \(failCount) failed") - EventLogger.log(event: "healthkit_sync_complete", withData: [ - "total": totalToSync, - "success": successCount, - "failed": failCount - ]) + AnalyticsManager.shared.track(.healthKitSyncCompleted(total: totalToSync, success: successCount, failed: failCount)) } // MARK: - Delete All Moods from HealthKit diff --git a/Shared/IAPManager.swift b/Shared/IAPManager.swift index 8f1fd3a..a8f35dd 100644 --- a/Shared/IAPManager.swift +++ b/Shared/IAPManager.swift @@ -15,9 +15,12 @@ import os.log enum SubscriptionState: Equatable { case unknown case subscribed(expirationDate: Date?, willAutoRenew: Bool) + case billingRetry(expirationDate: Date?, willAutoRenew: Bool) + case gracePeriod(expirationDate: Date?, willAutoRenew: Bool) case inTrial(daysRemaining: Int) case trialExpired case expired + case revoked } // MARK: - IAPManager @@ -84,16 +87,20 @@ class IAPManager: ObservableObject { // MARK: - Computed Properties var isSubscribed: Bool { - if case .subscribed = state { return true } - return false + switch state { + case .subscribed, .billingRetry, .gracePeriod: + return true + default: + return false + } } var hasFullAccess: Bool { if Self.bypassSubscription { return true } switch state { - case .subscribed, .inTrial: + case .subscribed, .billingRetry, .gracePeriod, .inTrial: return true - case .unknown, .trialExpired, .expired: + case .unknown, .trialExpired, .expired, .revoked: return false } } @@ -101,9 +108,9 @@ class IAPManager: ObservableObject { var shouldShowPaywall: Bool { if Self.bypassSubscription { return false } switch state { - case .trialExpired, .expired: + case .trialExpired, .expired, .revoked: return true - case .unknown, .subscribed, .inTrial: + case .unknown, .subscribed, .billingRetry, .gracePeriod, .inTrial: return false } } @@ -169,10 +176,28 @@ class IAPManager: ObservableObject { if hasActiveSubscription { // State already set in checkForActiveSubscription — cache it - if case .subscribed(let expiration, _) = state { + switch state { + case .subscribed(let expiration, _), + .billingRetry(let expiration, _), + .gracePeriod(let expiration, _): cacheSubscriptionExpiration(expiration) + default: + break } syncSubscriptionStatusToUserDefaults() + trackSubscriptionAnalytics(source: "status_check_active") + return + } + + // Preserve terminal StoreKit states (expired/revoked) instead of overriding with trial fallback. + if case .expired = state { + syncSubscriptionStatusToUserDefaults() + trackSubscriptionAnalytics(source: "status_check_terminal") + return + } + if case .revoked = state { + syncSubscriptionStatusToUserDefaults() + trackSubscriptionAnalytics(source: "status_check_terminal") return } @@ -182,12 +207,14 @@ class IAPManager: ObservableObject { if let expiration = cachedExpiration, expiration > Date() { state = .subscribed(expirationDate: expiration, willAutoRenew: false) syncSubscriptionStatusToUserDefaults() + trackSubscriptionAnalytics(source: "status_check_cached") return } // Subscription genuinely gone — clear cache and fall back to trial cacheSubscriptionExpiration(nil) updateTrialState() + trackSubscriptionAnalytics(source: "status_check_fallback") } /// Sync subscription status to UserDefaults for widget access @@ -218,11 +245,14 @@ class IAPManager: ObservableObject { } /// Restore purchases - func restore() async { + func restore(source: String = "settings") async { do { try await AppStore.sync() await checkSubscriptionStatus() + AnalyticsManager.shared.trackPurchaseRestored(source: source) + trackSubscriptionAnalytics(source: "restore") } catch { + AnalyticsManager.shared.trackPurchaseFailed(productId: nil, source: source, error: error.localizedDescription) AppLogger.iap.error("Failed to restore purchases: \(error.localizedDescription)") } } @@ -239,6 +269,8 @@ class IAPManager: ObservableObject { } private func checkForActiveSubscription() async -> Bool { + var nonActiveState: SubscriptionState? + for await result in Transaction.currentEntitlements { guard case .verified(let transaction) = result else { continue } @@ -255,23 +287,46 @@ class IAPManager: ObservableObject { if let product = currentProduct, let subscription = product.subscription, let statuses = try? await subscription.status { + var hadVerifiedStatus = false for status in statuses { guard case .verified(let renewalInfo) = status.renewalInfo else { continue } + hadVerifiedStatus = true switch status.state { - case .subscribed, .inGracePeriod, .inBillingRetryPeriod: + case .subscribed: state = .subscribed( expirationDate: transaction.expirationDate, willAutoRenew: renewalInfo.willAutoRenew ) return true - case .expired, .revoked: + case .inBillingRetryPeriod: + state = .billingRetry( + expirationDate: transaction.expirationDate, + willAutoRenew: renewalInfo.willAutoRenew + ) + return true + case .inGracePeriod: + state = .gracePeriod( + expirationDate: transaction.expirationDate, + willAutoRenew: renewalInfo.willAutoRenew + ) + return true + case .expired: + nonActiveState = .expired + continue + case .revoked: + nonActiveState = .revoked continue default: continue } } + + // We had detailed status and none were active, so do not fallback to subscribed. + if hadVerifiedStatus { + continue + } } // Fallback if we couldn't get detailed status @@ -280,7 +335,11 @@ class IAPManager: ObservableObject { } // No active subscription found - currentProduct = nil + if let nonActiveState { + state = nonActiveState + } else { + currentProduct = nil + } return false } @@ -297,6 +356,112 @@ class IAPManager: ObservableObject { syncSubscriptionStatusToUserDefaults() } + // MARK: - Analytics + + func trackSubscriptionAnalytics(source: String) { + let status: String + let isSubscribed: Bool + let hasFullAccess: Bool + let productId = currentProduct?.id + let type = subscriptionType(for: productId) + let willAutoRenew: Bool? + let isInGracePeriod: Bool? + let trialDaysRemaining: Int? + let expirationDate: Date? + + switch state { + case .unknown: + status = "unknown" + isSubscribed = false + hasFullAccess = Self.bypassSubscription + willAutoRenew = nil + isInGracePeriod = nil + trialDaysRemaining = nil + expirationDate = nil + case .subscribed(let expiration, let autoRenew): + status = "subscribed" + isSubscribed = true + hasFullAccess = true + willAutoRenew = autoRenew + isInGracePeriod = false + trialDaysRemaining = nil + expirationDate = expiration + case .billingRetry(let expiration, let autoRenew): + status = "billing_retry" + isSubscribed = true + hasFullAccess = true + willAutoRenew = autoRenew + isInGracePeriod = true + trialDaysRemaining = nil + expirationDate = expiration + case .gracePeriod(let expiration, let autoRenew): + status = "grace_period" + isSubscribed = true + hasFullAccess = true + willAutoRenew = autoRenew + isInGracePeriod = true + trialDaysRemaining = nil + expirationDate = expiration + case .inTrial(let daysRemaining): + status = "trial" + isSubscribed = false + hasFullAccess = true + willAutoRenew = nil + isInGracePeriod = nil + trialDaysRemaining = daysRemaining + expirationDate = nil + case .trialExpired: + status = "trial_expired" + isSubscribed = false + hasFullAccess = false + willAutoRenew = nil + isInGracePeriod = nil + trialDaysRemaining = nil + expirationDate = nil + case .expired: + status = "expired" + isSubscribed = false + hasFullAccess = false + willAutoRenew = nil + isInGracePeriod = nil + trialDaysRemaining = nil + expirationDate = nil + case .revoked: + status = "revoked" + isSubscribed = false + hasFullAccess = false + willAutoRenew = nil + isInGracePeriod = nil + trialDaysRemaining = nil + expirationDate = nil + } + + AnalyticsManager.shared.trackSubscriptionStatusObserved( + status: status, + type: type, + source: source, + isSubscribed: isSubscribed, + hasFullAccess: hasFullAccess, + productId: productId, + willAutoRenew: willAutoRenew, + isInGracePeriod: isInGracePeriod, + trialDaysRemaining: trialDaysRemaining, + expirationDate: expirationDate + ) + } + + private func subscriptionType(for productID: String?) -> String { + guard let productID else { return "none" } + let id = productID.lowercased() + if id.contains("annual") || id.contains("year") { + return "yearly" + } + if id.contains("month") { + return "monthly" + } + return "unknown" + } + private func listenForTransactions() -> Task { Task.detached { [weak self] in for await result in Transaction.updates { diff --git a/Shared/LocalNotification.swift b/Shared/LocalNotification.swift index 58dd76d..97c27e2 100644 --- a/Shared/LocalNotification.swift +++ b/Shared/LocalNotification.swift @@ -21,10 +21,10 @@ class LocalNotification { public class func testIfEnabled(completion: @escaping (Result) -> Void) { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { success, error in if success { - EventLogger.log(event: "local_notification_enabled") + AnalyticsManager.shared.track(.notificationEnabled) completion(.success(true)) } else if let error = error { - EventLogger.log(event: "local_notification_disabled") + AnalyticsManager.shared.track(.notificationDisabled) completion(.failure(error)) } } diff --git a/Shared/Models/AppTheme.swift b/Shared/Models/AppTheme.swift index 006708d..936cf53 100644 --- a/Shared/Models/AppTheme.swift +++ b/Shared/Models/AppTheme.swift @@ -266,7 +266,9 @@ enum AppTheme: Int, CaseIterable, Identifiable { GroupUserDefaults.groupDefaults.set(lockScreenStyle.rawValue, forKey: UserDefaultsStore.Keys.lockScreenStyle.rawValue) // Log the theme change - EventLogger.log(event: "apply_theme", withData: ["theme": name]) + Task { @MainActor in + AnalyticsManager.shared.track(.themeApplied(themeName: name)) + } } } diff --git a/Shared/Onboarding/views/OnboardingSubscription.swift b/Shared/Onboarding/views/OnboardingSubscription.swift index c33e6ec..5bde8d4 100644 --- a/Shared/Onboarding/views/OnboardingSubscription.swift +++ b/Shared/Onboarding/views/OnboardingSubscription.swift @@ -96,7 +96,7 @@ struct OnboardingSubscription: View { VStack(spacing: 12) { // Subscribe button Button(action: { - EventLogger.log(event: "onboarding_subscribe_tapped") + AnalyticsManager.shared.track(.onboardingSubscribeTapped) showSubscriptionStore = true }) { HStack { @@ -120,8 +120,8 @@ struct OnboardingSubscription: View { // Skip button Button(action: { - EventLogger.log(event: "onboarding_complete") - EventLogger.log(event: "onboarding_skip_subscription") + AnalyticsManager.shared.track(.onboardingCompleted(dayId: nil)) + AnalyticsManager.shared.track(.onboardingSkipped) completionClosure(onboardingData) }) { Text("Maybe Later") @@ -138,10 +138,10 @@ struct OnboardingSubscription: View { } .sheet(isPresented: $showSubscriptionStore, onDismiss: { // After subscription store closes, complete onboarding - EventLogger.log(event: "onboarding_complete") + AnalyticsManager.shared.track(.onboardingCompleted(dayId: nil)) completionClosure(onboardingData) }) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "onboarding") } } } diff --git a/Shared/Onboarding/views/OnboardingWrapup.swift b/Shared/Onboarding/views/OnboardingWrapup.swift index 2a56c99..4f087ce 100644 --- a/Shared/Onboarding/views/OnboardingWrapup.swift +++ b/Shared/Onboarding/views/OnboardingWrapup.swift @@ -60,9 +60,7 @@ struct OnboardingWrapup: View { .foregroundColor(Color(UIColor.white)) Button(action: { - EventLogger.log(event: "onboarding_complete") - EventLogger.log(event: "onboarding_complete_day_id", - withData: ["id": onboardingData.inputDay.rawValue]) + AnalyticsManager.shared.track(.onboardingCompleted(dayId: String(onboardingData.inputDay.rawValue))) completionClosure(onboardingData) }, label: { Text(String(localized: "onboarding_wrap_up_complete_button")) diff --git a/Shared/Persisence/DataControllerADD.swift b/Shared/Persisence/DataControllerADD.swift index 5001135..a134ebf 100644 --- a/Shared/Persisence/DataControllerADD.swift +++ b/Shared/Persisence/DataControllerADD.swift @@ -26,7 +26,7 @@ extension DataController { ) modelContext.insert(entry) - EventLogger.log(event: "add_entry", withData: ["entry_type": entryType.rawValue]) + AnalyticsManager.shared.track(.moodLogged(mood: mood.rawValue, entryType: String(describing: entryType))) saveAndRunDataListeners() } @@ -69,7 +69,7 @@ extension DataController { // Single save and listener notification at the end saveAndRunDataListeners() - EventLogger.log(event: "filled_in_missing_entries", withData: ["count": missing.count]) + AnalyticsManager.shared.track(.missingEntriesFilled(count: missing.count)) } func fixWrongWeekdays() { @@ -83,6 +83,5 @@ extension DataController { func removeNoForDates() { // Note: With SwiftData's non-optional forDate, this is essentially a no-op // Keeping for API compatibility - EventLogger.log(event: "removed_entry_no_for_date", withData: ["count": 0]) } } diff --git a/Shared/Persisence/DataControllerUPDATE.swift b/Shared/Persisence/DataControllerUPDATE.swift index a9f48a5..67a2170 100644 --- a/Shared/Persisence/DataControllerUPDATE.swift +++ b/Shared/Persisence/DataControllerUPDATE.swift @@ -18,7 +18,7 @@ extension DataController { entry.moodValue = mood.rawValue saveAndRunDataListeners() - EventLogger.log(event: "update_entry") + AnalyticsManager.shared.track(.moodUpdated(mood: mood.rawValue)) return true } @@ -33,7 +33,7 @@ extension DataController { entry.notes = notes saveAndRunDataListeners() - EventLogger.log(event: "update_notes") + AnalyticsManager.shared.track(.noteUpdated(characterCount: (notes ?? "").count)) return true } @@ -48,7 +48,11 @@ extension DataController { entry.photoID = photoID saveAndRunDataListeners() - EventLogger.log(event: "update_photo") + if photoID != nil { + AnalyticsManager.shared.track(.photoAdded) + } else { + AnalyticsManager.shared.track(.photoDeleted) + } return true } } diff --git a/Shared/Services/BiometricAuthManager.swift b/Shared/Services/BiometricAuthManager.swift index 2fa22d2..68aefc6 100644 --- a/Shared/Services/BiometricAuthManager.swift +++ b/Shared/Services/BiometricAuthManager.swift @@ -98,12 +98,12 @@ class BiometricAuthManager: ObservableObject { isUnlocked = success if success { - EventLogger.log(event: "biometric_unlock_success") + AnalyticsManager.shared.track(.biometricUnlockSuccess) } return success } catch { print("Authentication failed: \(error.localizedDescription)") - EventLogger.log(event: "biometric_unlock_failed", withData: ["error": error.localizedDescription]) + AnalyticsManager.shared.track(.biometricUnlockFailed(error: error.localizedDescription)) // If biometrics failed, try device passcode as fallback if canUseDevicePasscode && policy == .deviceOwnerAuthenticationWithBiometrics { @@ -136,7 +136,7 @@ class BiometricAuthManager: ObservableObject { func lock() { guard isLockEnabled else { return } isUnlocked = false - EventLogger.log(event: "app_locked") + AnalyticsManager.shared.track(.appLocked) } func enableLock() async -> Bool { @@ -159,7 +159,7 @@ class BiometricAuthManager: ObservableObject { if success { isLockEnabled = true isUnlocked = true - EventLogger.log(event: "privacy_lock_enabled") + AnalyticsManager.shared.track(.privacyLockEnabled) } return success @@ -172,6 +172,6 @@ class BiometricAuthManager: ObservableObject { func disableLock() { isLockEnabled = false isUnlocked = true - EventLogger.log(event: "privacy_lock_disabled") + AnalyticsManager.shared.track(.privacyLockDisabled) } } diff --git a/Shared/Services/ExportService.swift b/Shared/Services/ExportService.swift index 684e446..139058d 100644 --- a/Shared/Services/ExportService.swift +++ b/Shared/Services/ExportService.swift @@ -45,6 +45,12 @@ class ExportService { .horrible: UIColor(red: 0.91, green: 0.30, blue: 0.24, alpha: 1.0) // Red ] + private func trackDataExported(format: String, count: Int) { + Task { @MainActor in + AnalyticsManager.shared.track(.dataExported(format: format, count: count)) + } + } + // MARK: - CSV Export func generateCSV(entries: [MoodEntryModel]) -> String { @@ -75,7 +81,7 @@ class ExportService { do { try csv.write(to: tempURL, atomically: true, encoding: .utf8) - EventLogger.log(event: "csv_exported", withData: ["count": entries.count]) + trackDataExported(format: "csv", count: entries.count) return tempURL } catch { print("ExportService: Failed to write CSV: \(error)") @@ -152,7 +158,7 @@ class ExportService { drawFooter(pageWidth: pageWidth, pageHeight: pageHeight, margin: margin, in: context) } - EventLogger.log(event: "pdf_exported", withData: ["count": entries.count]) + trackDataExported(format: "pdf", count: entries.count) return data } diff --git a/Shared/Services/HealthService.swift b/Shared/Services/HealthService.swift index 7050ebc..538e9d6 100644 --- a/Shared/Services/HealthService.swift +++ b/Shared/Services/HealthService.swift @@ -79,11 +79,11 @@ class HealthService: ObservableObject { try await healthStore.requestAuthorization(toShare: [], read: readTypes) isAuthorized = true isEnabled = true - EventLogger.log(event: "healthkit_authorized") + AnalyticsManager.shared.track(.healthKitAuthorized) return true } catch { print("HealthService: Authorization failed: \(error.localizedDescription)") - EventLogger.log(event: "healthkit_auth_failed", withData: ["error": error.localizedDescription]) + AnalyticsManager.shared.track(.healthKitAuthFailed(error: error.localizedDescription)) return false } } diff --git a/Shared/Services/PhotoManager.swift b/Shared/Services/PhotoManager.swift index 759add3..d0dabee 100644 --- a/Shared/Services/PhotoManager.swift +++ b/Shared/Services/PhotoManager.swift @@ -95,7 +95,7 @@ class PhotoManager: ObservableObject { try? thumbnailData.write(to: thumbnailURL) } - EventLogger.log(event: "photo_saved") + AnalyticsManager.shared.track(.photoAdded) return photoID } @@ -163,7 +163,7 @@ class PhotoManager: ObservableObject { } if success { - EventLogger.log(event: "photo_deleted") + AnalyticsManager.shared.track(.photoDeleted) } return success diff --git a/Shared/Views/CustomIcon/CreateWidgetView.swift b/Shared/Views/CustomIcon/CreateWidgetView.swift index a35d4da..43c7305 100644 --- a/Shared/Views/CustomIcon/CreateWidgetView.swift +++ b/Shared/Views/CustomIcon/CreateWidgetView.swift @@ -46,8 +46,7 @@ struct CreateWidgetView: View { } func update(eye: CustomWidgetEyes, eyeOption: CustomWidgeImageOptions) { - EventLogger.log(event: "create_widget_view_update_eye", - withData: ["eye_value": eye.rawValue, "eye_option_value": eyeOption.rawValue]) + AnalyticsManager.shared.track(.widgetEyeUpdated(style: eyeOption.rawValue)) switch eye { case .left: customWidget.leftEye = eyeOption @@ -57,7 +56,7 @@ struct CreateWidgetView: View { } func createRandom() { - EventLogger.log(event: "create_widget_view_create_random") + AnalyticsManager.shared.track(.widgetRandomized) customWidget.bgColor = Color.random() customWidget.innerColor = Color.random() customWidget.bgOverlayColor = Color.random() @@ -74,14 +73,12 @@ struct CreateWidgetView: View { } func update(mouthOption: CustomWidgeImageOptions) { - EventLogger.log(event: "create_widget_view_update_mouth", - withData: ["mouthOption": mouthOption.rawValue]) + AnalyticsManager.shared.track(.widgetMouthUpdated(style: mouthOption.rawValue)) customWidget.mouth = mouthOption } func update(background: CustomWidgetBackGroundOptions) { - EventLogger.log(event: "create_widget_view_update_background", - withData: ["background": background.rawValue]) + AnalyticsManager.shared.track(.widgetBackgroundUpdated(style: background.rawValue)) customWidget.background = background } @@ -101,7 +98,7 @@ struct CreateWidgetView: View { var bottomBarButtons: some View { HStack(alignment: .center, spacing: 0) { Button(action: { - EventLogger.log(event: "create_widget_view_shuffle") + AnalyticsManager.shared.track(.widgetShuffled) createRandom() }, label: { Image(systemName: "shuffle") @@ -114,7 +111,7 @@ struct CreateWidgetView: View { .background(.blue) Button(action: { - EventLogger.log(event: "create_widget_view_save_widget") + AnalyticsManager.shared.track(.widgetCreated) UserDefaultsStore.saveCustomWidget(widgetModel: customWidget, inUse: false) let impactMed = UIImpactFeedbackGenerator(style: .heavy) impactMed.impactOccurred() @@ -132,7 +129,7 @@ struct CreateWidgetView: View { .background(.green) Button(action: { - EventLogger.log(event: "customize_view_use_widget") + AnalyticsManager.shared.track(.widgetUsed) UserDefaultsStore.saveCustomWidget(widgetModel: customWidget, inUse: true) let impactMed = UIImpactFeedbackGenerator(style: .heavy) impactMed.impactOccurred() @@ -151,7 +148,7 @@ struct CreateWidgetView: View { if customWidget.isSaved { Button(action: { - EventLogger.log(event: "customize_view_delete_widget") + AnalyticsManager.shared.track(.widgetDeleted) UserDefaultsStore.deleteCustomWidget(withUUID: customWidget.uuid) let impactMed = UIImpactFeedbackGenerator(style: .heavy) impactMed.impactOccurred() @@ -178,7 +175,7 @@ struct CreateWidgetView: View { Text(String(localized: "create_widget_background_color")) ColorPicker("", selection: $customWidget.bgColor) .onChange(of: customWidget.bgColor) { - EventLogger.log(event: "create_widget_view_update_background_color") + AnalyticsManager.shared.track(.widgetColorUpdated(part: "background")) } .labelsHidden() } @@ -188,7 +185,7 @@ struct CreateWidgetView: View { Text(String(localized: "create_widget_inner_color")) ColorPicker("", selection: $customWidget.innerColor) .onChange(of: customWidget.innerColor) { - EventLogger.log(event: "create_widget_view_update_inner_color") + AnalyticsManager.shared.track(.widgetColorUpdated(part: "inner")) } .labelsHidden() } @@ -198,7 +195,7 @@ struct CreateWidgetView: View { Text(String(localized: "create_widget_face_outline_color")) ColorPicker("", selection: $customWidget.circleStrokeColor) .onChange(of: customWidget.circleStrokeColor) { - EventLogger.log(event: "create_widget_view_update_outline_color") + AnalyticsManager.shared.track(.widgetColorUpdated(part: "outline")) } .labelsHidden() } @@ -210,7 +207,7 @@ struct CreateWidgetView: View { Text(String(localized: "create_widget_view_left_eye_color")) ColorPicker("", selection: $customWidget.leftEyeColor) .onChange(of: customWidget.leftEyeColor) { - EventLogger.log(event: "create_widget_view_update_left_eye_color") + AnalyticsManager.shared.track(.widgetColorUpdated(part: "left_eye")) } .labelsHidden() } @@ -220,7 +217,7 @@ struct CreateWidgetView: View { Text(String(localized: "create_widget_view_right_eye_color")) ColorPicker("", selection: $customWidget.rightEyeColor) .onChange(of: customWidget.rightEyeColor) { - EventLogger.log(event: "create_widget_view_update_right_eye_color") + AnalyticsManager.shared.track(.widgetColorUpdated(part: "right_eye")) } .labelsHidden() } @@ -230,7 +227,7 @@ struct CreateWidgetView: View { Text(String(localized: "create_widget_view_mouth_color")) ColorPicker("", selection: $customWidget.mouthColor) .onChange(of: customWidget.mouthColor) { - EventLogger.log(event: "create_widget_view_update_mouth_color") + AnalyticsManager.shared.track(.widgetColorUpdated(part: "mouth")) } .labelsHidden() } diff --git a/Shared/Views/CustomizeView/CustomizeView.swift b/Shared/Views/CustomizeView/CustomizeView.swift index 346c876..ff09ad1 100644 --- a/Shared/Views/CustomizeView/CustomizeView.swift +++ b/Shared/Views/CustomizeView/CustomizeView.swift @@ -106,7 +106,7 @@ struct CustomizeContentView: View { .padding(.bottom, 32) } .onAppear(perform: { - EventLogger.log(event: "show_customize_view") + AnalyticsManager.shared.trackScreen(.customize) }) .customizeLayoutTip() } @@ -175,10 +175,10 @@ struct CustomizeView: View { .padding(.bottom, 32) } .onAppear(perform: { - EventLogger.log(event: "show_customize_view") + AnalyticsManager.shared.trackScreen(.customize) }) .sheet(isPresented: $showSubscriptionStore) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "customize") } .background( theme.currentTheme.bg @@ -253,7 +253,7 @@ struct ThemePickerCompact: View { ForEach(Theme.allCases, id: \.rawValue) { aTheme in Button(action: { theme = aTheme - EventLogger.log(event: "change_theme_id", withData: ["id": aTheme.rawValue]) + AnalyticsManager.shared.track(.themeChanged(themeId: aTheme.rawValue)) }) { VStack(spacing: 8) { ZStack { @@ -300,7 +300,7 @@ struct ImagePackPickerCompact: View { let impactMed = UIImpactFeedbackGenerator(style: .medium) impactMed.impactOccurred() imagePack = images - EventLogger.log(event: "change_image_pack_id", withData: ["id": images.rawValue]) + AnalyticsManager.shared.track(.iconPackChanged(packId: images.rawValue)) }) { HStack { HStack(spacing: 16) { @@ -358,7 +358,7 @@ struct VotingLayoutPickerCompact: View { votingLayoutStyle = layout.rawValue } } - EventLogger.log(event: "change_voting_layout", withData: ["layout": layout.displayName]) + AnalyticsManager.shared.track(.votingLayoutChanged(layout: layout.displayName)) }) { VStack(spacing: 6) { layoutIcon(for: layout) @@ -490,7 +490,7 @@ struct CustomWidgetSection: View { .frame(width: 60, height: 60) .cornerRadius(12) .onTapGesture { - EventLogger.log(event: "show_widget") + AnalyticsManager.shared.track(.widgetViewed) selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel selectedWidget.showSheet = true } @@ -498,7 +498,7 @@ struct CustomWidgetSection: View { // Add button Button(action: { - EventLogger.log(event: "tap_create_new_widget") + AnalyticsManager.shared.track(.widgetCreateTapped) selectedWidget.selectedItem = CustomWidgetModel.randomWidget selectedWidget.showSheet = true }) { @@ -547,12 +547,11 @@ struct PersonalityPackPickerCompact: View { Button(action: { // if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW { // showOver18Alert = true -// EventLogger.log(event: "show_over_18_alert") // } else { let impactMed = UIImpactFeedbackGenerator(style: .medium) impactMed.impactOccurred() personalityPack = aPack - EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()]) + AnalyticsManager.shared.track(.personalityPackChanged(packTitle: aPack.title())) LocalNotification.rescheduleNotifiations() // } }) { @@ -651,7 +650,7 @@ struct SubscriptionBannerView: View { private var notSubscribedView: some View { Button(action: { - EventLogger.log(event: "customize_subscribe_tapped") + AnalyticsManager.shared.track(.paywallSubscribeTapped(source: "customize")) showSubscriptionStore = true }) { HStack(spacing: 12) { @@ -722,7 +721,7 @@ struct DayViewStylePickerCompact: View { } let impactMed = UIImpactFeedbackGenerator(style: .medium) impactMed.impactOccurred() - EventLogger.log(event: "change_day_view_style", withData: ["style": style.displayName]) + AnalyticsManager.shared.track(.dayViewStyleChanged(style: style.displayName)) }) { VStack(spacing: 6) { styleIcon(for: style) diff --git a/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift b/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift index 7da002e..e9acd8a 100644 --- a/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift +++ b/Shared/Views/CustomizeView/SubViews/CustomWigetView.swift @@ -24,7 +24,7 @@ struct CustomWigetView: View { .frame(width: 50, height: 50) .cornerRadius(10) .onTapGesture { - EventLogger.log(event: "show_widget") + AnalyticsManager.shared.track(.widgetViewed) selectedWidget.selectedItem = widget.copy() as? CustomWidgetModel selectedWidget.showSheet = true } @@ -35,7 +35,7 @@ struct CustomWigetView: View { Image(systemName: "plus") ) .onTapGesture { - EventLogger.log(event: "tap_create_new_widget") + AnalyticsManager.shared.track(.widgetCreateTapped) selectedWidget.selectedItem = CustomWidgetModel.randomWidget selectedWidget.showSheet = true } diff --git a/Shared/Views/CustomizeView/SubViews/IconPickerView.swift b/Shared/Views/CustomizeView/SubViews/IconPickerView.swift index 3ea81a6..ca6b9ff 100644 --- a/Shared/Views/CustomizeView/SubViews/IconPickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/IconPickerView.swift @@ -57,7 +57,7 @@ struct IconPickerView: View { HStack { Button(action: { UIApplication.shared.setAlternateIconName(nil) - EventLogger.log(event: "change_icon_title", withData: ["title": "default"]) + AnalyticsManager.shared.track(.appIconChanged(iconTitle: "default")) }, label: { Image("AppIconImage", bundle: .main) .resizable() @@ -73,7 +73,7 @@ struct IconPickerView: View { UIApplication.shared.setAlternateIconName(iconSet.1) { (error) in // FIXME: Handle error } - EventLogger.log(event: "change_icon_title", withData: ["title": iconSet.1]) + AnalyticsManager.shared.track(.appIconChanged(iconTitle: iconSet.1)) }, label: { Image(iconSet.0, bundle: .main) .resizable() diff --git a/Shared/Views/CustomizeView/SubViews/ImagePackPickerView.swift b/Shared/Views/CustomizeView/SubViews/ImagePackPickerView.swift index 24dc355..e8a5ef9 100644 --- a/Shared/Views/CustomizeView/SubViews/ImagePackPickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/ImagePackPickerView.swift @@ -45,7 +45,7 @@ struct ImagePackPickerView: View { let impactMed = UIImpactFeedbackGenerator(style: .heavy) impactMed.impactOccurred() imagePack = images - EventLogger.log(event: "change_image_pack_id", withData: ["id": images.rawValue]) + AnalyticsManager.shared.track(.iconPackChanged(packId: images.rawValue)) } if images.rawValue != (MoodImages.allCases.sorted(by: { $0.rawValue > $1.rawValue }).first?.rawValue) ?? 0 { Divider() diff --git a/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift b/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift index e5c3f4e..9615508 100644 --- a/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/PersonalityPackPickerView.swift @@ -41,14 +41,10 @@ struct PersonalityPackPickerView: View { .padding(5) ) .onTapGesture { -// if aPack.rawValue == PersonalityPack.Rude.rawValue && !showNSFW { -// showOver18Alert = true -// EventLogger.log(event: "show_over_18_alert") -// } else { let impactMed = UIImpactFeedbackGenerator(style: .heavy) impactMed.impactOccurred() personalityPack = aPack - EventLogger.log(event: "change_personality_pack", withData: ["pack_title": aPack.title()]) + AnalyticsManager.shared.track(.personalityPackChanged(packTitle: aPack.title())) LocalNotification.rescheduleNotifiations() // } } diff --git a/Shared/Views/CustomizeView/SubViews/ShapePickerView.swift b/Shared/Views/CustomizeView/SubViews/ShapePickerView.swift index 9eadff8..28f1d76 100644 --- a/Shared/Views/CustomizeView/SubViews/ShapePickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/ShapePickerView.swift @@ -47,7 +47,7 @@ struct ShapePickerView: View { let impactMed = UIImpactFeedbackGenerator(style: .heavy) impactMed.impactOccurred() shape = ashape - EventLogger.log(event: "change_mood_shape_id", withData: ["id": shape.rawValue]) + AnalyticsManager.shared.track(.moodShapeChanged(shapeId: shape.rawValue)) } .contentShape(Rectangle()) .background( diff --git a/Shared/Views/CustomizeView/SubViews/ThemePickerView.swift b/Shared/Views/CustomizeView/SubViews/ThemePickerView.swift index 073228c..20507fb 100644 --- a/Shared/Views/CustomizeView/SubViews/ThemePickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/ThemePickerView.swift @@ -71,7 +71,7 @@ struct ThemePickerView: View { selectedTheme = theme } - EventLogger.log(event: "change_theme_id", withData: ["id": theme.rawValue]) + AnalyticsManager.shared.track(.themeChanged(themeId: theme.rawValue)) } } diff --git a/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift b/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift index 9532270..ab5eb15 100644 --- a/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift +++ b/Shared/Views/CustomizeView/SubViews/VotingLayoutPickerView.swift @@ -39,7 +39,7 @@ struct VotingLayoutPickerView: View { votingLayoutStyle = layout.rawValue } } - EventLogger.log(event: "change_voting_layout", withData: ["layout": layout.displayName]) + AnalyticsManager.shared.track(.votingLayoutChanged(layout: layout.displayName)) }) { VStack(spacing: 6) { layoutIcon(for: layout) diff --git a/Shared/Views/DayView/DayView.swift b/Shared/Views/DayView/DayView.swift index 2e1c4b3..ee65aec 100644 --- a/Shared/Views/DayView/DayView.swift +++ b/Shared/Views/DayView/DayView.swift @@ -40,7 +40,7 @@ struct DayView: View { ZStack { mainView .onAppear(perform: { - EventLogger.log(event: "show_home_view") + AnalyticsManager.shared.trackScreen(.day) }) .sheet(isPresented: $showingSheet) { SettingsView() diff --git a/Shared/Views/FeelsSubscriptionStoreView.swift b/Shared/Views/FeelsSubscriptionStoreView.swift index 59de0ed..3e60204 100644 --- a/Shared/Views/FeelsSubscriptionStoreView.swift +++ b/Shared/Views/FeelsSubscriptionStoreView.swift @@ -16,6 +16,7 @@ struct FeelsSubscriptionStoreView: View { @AppStorage(UserDefaultsStore.Keys.paywallStyle.rawValue, store: GroupUserDefaults.groupDefaults) private var paywallStyleRaw: Int = 0 + var source: String = "unknown" var style: PaywallStyle? private var currentStyle: PaywallStyle { @@ -33,9 +34,29 @@ struct FeelsSubscriptionStoreView: View { .storeButton(.visible, for: .restorePurchases) .subscriptionStoreButtonLabel(.multiline) .tint(tintColor) - .onInAppPurchaseCompletion { _, result in - if case .success(.success(_)) = result { + .onAppear { + AnalyticsManager.shared.trackPaywallViewed(source: source) + } + .onInAppPurchaseStart { product in + AnalyticsManager.shared.trackPurchaseStarted(productId: product.id, source: source) + } + .onInAppPurchaseCompletion { product, result in + switch result { + case .success(.success(_)): + AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: source) + Task { @MainActor in + await iapManager.checkSubscriptionStatus() + iapManager.trackSubscriptionAnalytics(source: "purchase_success") + } dismiss() + case .success(.userCancelled): + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "user_cancelled") + case .success(.pending): + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "pending") + case .failure(let error): + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: error.localizedDescription) + @unknown default: + AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "unknown_result") } } } diff --git a/Shared/Views/IAPWarningView.swift b/Shared/Views/IAPWarningView.swift index d39bfb4..cc59923 100644 --- a/Shared/Views/IAPWarningView.swift +++ b/Shared/Views/IAPWarningView.swift @@ -53,7 +53,7 @@ struct IAPWarningView: View { .padding() .background(theme.currentTheme.secondaryBGColor) .sheet(isPresented: $showSubscriptionStore) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "iap_warning") } } } diff --git a/Shared/Views/InsightsView/InsightsView.swift b/Shared/Views/InsightsView/InsightsView.swift index 941dc89..af1423f 100644 --- a/Shared/Views/InsightsView/InsightsView.swift +++ b/Shared/Views/InsightsView/InsightsView.swift @@ -171,14 +171,14 @@ struct InsightsView: View { } } .sheet(isPresented: $showSubscriptionStore) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "insights_gate") } .background( theme.currentTheme.bg .edgesIgnoringSafeArea(.all) ) .onAppear { - EventLogger.log(event: "show_insights_view") + AnalyticsManager.shared.trackScreen(.insights) viewModel.generateInsights() } .padding(.top) diff --git a/Shared/Views/MonthView/MonthDetailView.swift b/Shared/Views/MonthView/MonthDetailView.swift index 563fad2..5f24f68 100644 --- a/Shared/Views/MonthView/MonthDetailView.swift +++ b/Shared/Views/MonthView/MonthDetailView.swift @@ -77,7 +77,7 @@ struct MonthDetailView: View { ) } .onAppear(perform: { - EventLogger.log(event: "show_month_detail_view") + AnalyticsManager.shared.trackScreen(.monthDetail) }) .background( theme.currentTheme.bg diff --git a/Shared/Views/MonthView/MonthView.swift b/Shared/Views/MonthView/MonthView.swift index 5c75e18..9e1acd4 100644 --- a/Shared/Views/MonthView/MonthView.swift +++ b/Shared/Views/MonthView/MonthView.swift @@ -337,10 +337,10 @@ struct MonthView: View { } } .sheet(isPresented: $showSubscriptionStore) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "month_gate") } .onAppear(perform: { - EventLogger.log(event: "show_month_view") + AnalyticsManager.shared.trackScreen(.month) }) .padding([.top]) .background( diff --git a/Shared/Views/PurchaseButtonView.swift b/Shared/Views/PurchaseButtonView.swift index 66cf21a..a9c6a02 100644 --- a/Shared/Views/PurchaseButtonView.swift +++ b/Shared/Views/PurchaseButtonView.swift @@ -32,7 +32,7 @@ struct PurchaseButtonView: View { .background(theme.currentTheme.secondaryBGColor) .cornerRadius(10) .sheet(isPresented: $showSubscriptionStore) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "purchase_button") } .manageSubscriptionsSheet(isPresented: $showManageSubscriptions) } @@ -104,7 +104,8 @@ struct PurchaseButtonView: View { private var subscriptionStatusBadge: some View { Group { - if case .subscribed(_, let willAutoRenew) = iapManager.state { + switch iapManager.state { + case .subscribed(_, let willAutoRenew): if willAutoRenew { Text(String(localized: "subscription_status_active")) .font(.caption) @@ -122,6 +123,16 @@ struct PurchaseButtonView: View { .background(Color.orange) .cornerRadius(4) } + case .billingRetry, .gracePeriod: + Text("Payment Issue") + .font(.caption) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Color.orange) + .cornerRadius(4) + default: + EmptyView() } } } @@ -166,7 +177,7 @@ struct PurchaseButtonView: View { // Restore purchases Button { Task { - await iapManager.restore() + await iapManager.restore(source: "purchase_button") } } label: { Text(String(localized: "purchase_view_restore")) diff --git a/Shared/Views/SettingsView/PaywallPreviewSettingsView.swift b/Shared/Views/SettingsView/PaywallPreviewSettingsView.swift index 94fd63f..d9bfc1c 100644 --- a/Shared/Views/SettingsView/PaywallPreviewSettingsView.swift +++ b/Shared/Views/SettingsView/PaywallPreviewSettingsView.swift @@ -38,7 +38,7 @@ struct PaywallPreviewSettingsView: View { } } .sheet(isPresented: $showFullPreview) { - FeelsSubscriptionStoreView(style: selectedStyle) + FeelsSubscriptionStoreView(source: "paywall_preview", style: selectedStyle) .environmentObject(iapManager) } } diff --git a/Shared/Views/SettingsView/SettingsTabView.swift b/Shared/Views/SettingsView/SettingsTabView.swift index e01769d..6579647 100644 --- a/Shared/Views/SettingsView/SettingsTabView.swift +++ b/Shared/Views/SettingsView/SettingsTabView.swift @@ -73,7 +73,7 @@ struct SettingsTabView: View { WhyUpgradeView() } .sheet(isPresented: $showSubscriptionStore) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "settings_tab") .environmentObject(iapManager) } } diff --git a/Shared/Views/SettingsView/SettingsView.swift b/Shared/Views/SettingsView/SettingsView.swift index d0181fc..50e2191 100644 --- a/Shared/Views/SettingsView/SettingsView.swift +++ b/Shared/Views/SettingsView/SettingsView.swift @@ -60,6 +60,7 @@ struct SettingsContentView: View { legalSectionHeader eulaButton privacyButton + analyticsToggle addTestDataButton @@ -108,7 +109,7 @@ struct SettingsContentView: View { ReminderTimePickerView() } .onAppear(perform: { - EventLogger.log(event: "show_settings_view") + AnalyticsManager.shared.trackScreen(.settings) FeelsTipsManager.shared.onSettingsViewed() }) } @@ -119,7 +120,7 @@ struct SettingsContentView: View { ZStack { theme.currentTheme.secondaryBGColor Button(action: { - EventLogger.log(event: "tap_reminder_time") + AnalyticsManager.shared.track(.reminderTimeTapped) showReminderTimePicker = true }, label: { HStack(spacing: 12) { @@ -832,7 +833,7 @@ struct SettingsContentView: View { if newValue { let success = await authManager.enableLock() if !success { - EventLogger.log(event: "privacy_lock_enable_failed") + AnalyticsManager.shared.track(.privacyLockEnableFailed) } } else { authManager.disableLock() @@ -927,16 +928,16 @@ struct SettingsContentView: View { // Sync all existing moods to HealthKit await HealthKitManager.shared.syncAllMoods() } else { - EventLogger.log(event: "healthkit_state_of_mind_not_authorized") + AnalyticsManager.shared.track(.healthKitNotAuthorized) } } catch { print("HealthKit authorization failed: \(error)") - EventLogger.log(event: "healthkit_enable_failed") + AnalyticsManager.shared.track(.healthKitEnableFailed) } } } else { healthService.isEnabled = false - EventLogger.log(event: "healthkit_disabled") + AnalyticsManager.shared.track(.healthKitDisabled) } } )) @@ -988,7 +989,7 @@ struct SettingsContentView: View { .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .healthKitSyncTip() .sheet(isPresented: $showSubscriptionStore) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "settings") } } @@ -998,7 +999,7 @@ struct SettingsContentView: View { ZStack { theme.currentTheme.secondaryBGColor Button(action: { - EventLogger.log(event: "tap_export_data") + AnalyticsManager.shared.track(.exportTapped) showExportView = true }, label: { HStack(spacing: 12) { @@ -1035,7 +1036,7 @@ struct SettingsContentView: View { ZStack { theme.currentTheme.secondaryBGColor Button(action: { - EventLogger.log(event: "tap_show_onboarding") + AnalyticsManager.shared.track(.onboardingReshown) showOnboarding.toggle() }, label: { Text(String(localized: "settings_view_show_onboarding")) @@ -1055,7 +1056,7 @@ struct SettingsContentView: View { Toggle(String(localized: "settings_use_delete_enable"), isOn: $deleteEnabled) .onChange(of: deleteEnabled) { _, newValue in - EventLogger.log(event: "toggle_can_delete", withData: ["value": newValue]) + AnalyticsManager.shared.track(.deleteToggleChanged(enabled: newValue)) } .foregroundColor(textColor) .accessibilityHint(String(localized: "Allow deleting mood entries by swiping")) @@ -1070,7 +1071,7 @@ struct SettingsContentView: View { ZStack { theme.currentTheme.secondaryBGColor Button(action: { - EventLogger.log(event: "show_eula") + AnalyticsManager.shared.track(.eulaViewed) if let url = URL(string: "https://feels.app/eula.html") { UIApplication.shared.open(url) } @@ -1089,7 +1090,7 @@ struct SettingsContentView: View { ZStack { theme.currentTheme.secondaryBGColor Button(action: { - EventLogger.log(event: "show_privacy") + AnalyticsManager.shared.track(.privacyPolicyViewed) if let url = URL(string: "https://feels.app/privacy.html") { UIApplication.shared.open(url) } @@ -1104,6 +1105,48 @@ struct SettingsContentView: View { .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } + // MARK: - Analytics Toggle + + private var analyticsToggle: some View { + ZStack { + theme.currentTheme.secondaryBGColor + HStack(spacing: 12) { + Image(systemName: "chart.bar.xaxis") + .font(.title2) + .foregroundColor(.accentColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text("Share Analytics") + .foregroundColor(textColor) + + Text("Help improve Feels by sharing anonymous usage data") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Toggle("", isOn: Binding( + get: { !AnalyticsManager.shared.isOptedOut }, + set: { enabled in + if enabled { + AnalyticsManager.shared.optIn() + } else { + AnalyticsManager.shared.optOut() + } + } + )) + .labelsHidden() + .accessibilityLabel("Share Analytics") + .accessibilityHint("Toggle anonymous usage analytics") + } + .padding() + } + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + } + // MARK: - Helper Functions private func openInFilesApp(_ url: URL) { @@ -1175,7 +1218,7 @@ struct ReminderTimePickerView: View { // This handles notification scheduling and Live Activity rescheduling OnboardingDataDataManager.shared.updateOnboardingData(onboardingData: onboardingData) - EventLogger.log(event: "reminder_time_updated") + AnalyticsManager.shared.track(.reminderTimeUpdated) } } @@ -1231,6 +1274,7 @@ struct SettingsView: View { legalSectionHeader eulaButton privacyButton + analyticsToggle // specialThanksCell } @@ -1268,7 +1312,7 @@ struct SettingsView: View { )) } .onAppear(perform: { - EventLogger.log(event: "show_settings_view") + AnalyticsManager.shared.trackScreen(.settings) }) .background( theme.currentTheme.bg @@ -1282,7 +1326,7 @@ struct SettingsView: View { onCompletion: { result in switch result { case .success(let url): - EventLogger.log(event: "exported_file") + AnalyticsManager.shared.track(.dataExported(format: "file", count: 0)) print("Saved to \(url)") case .failure(let error): print(error.localizedDescription) @@ -1319,13 +1363,13 @@ struct SettingsView: View { DataController.shared.add(mood: mood, forDate: forDate, entryType: entryType) } DataController.shared.saveAndRunDataListeners() - EventLogger.log(event: "import_file") + AnalyticsManager.shared.track(.importSucceeded) } else { - EventLogger.log(event: "error_import_file") + AnalyticsManager.shared.track(.importFailed(error: nil)) } } catch { // Handle failure. - EventLogger.log(event: "error_import_file", withData: ["error": error.localizedDescription]) + AnalyticsManager.shared.track(.importFailed(error: error.localizedDescription)) print("Unable to read file contents") print(error.localizedDescription) } @@ -1402,7 +1446,7 @@ struct SettingsView: View { if newValue { let success = await authManager.enableLock() if !success { - EventLogger.log(event: "privacy_lock_enable_failed") + AnalyticsManager.shared.track(.privacyLockEnableFailed) } } else { authManager.disableLock() @@ -1465,16 +1509,16 @@ struct SettingsView: View { // Sync all existing moods to HealthKit await HealthKitManager.shared.syncAllMoods() } else { - EventLogger.log(event: "healthkit_state_of_mind_not_authorized") + AnalyticsManager.shared.track(.healthKitNotAuthorized) } } catch { print("HealthKit authorization failed: \(error)") - EventLogger.log(event: "healthkit_enable_failed") + AnalyticsManager.shared.track(.healthKitEnableFailed) } } } else { healthService.isEnabled = false - EventLogger.log(event: "healthkit_disabled") + AnalyticsManager.shared.track(.healthKitDisabled) } } )) @@ -1518,7 +1562,7 @@ struct SettingsView: View { .background(theme.currentTheme.secondaryBGColor) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) .sheet(isPresented: $showSubscriptionStore) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "settings") } } @@ -1528,7 +1572,7 @@ struct SettingsView: View { ZStack { theme.currentTheme.secondaryBGColor Button(action: { - EventLogger.log(event: "tap_export_data") + AnalyticsManager.shared.track(.exportTapped) showExportView = true }, label: { HStack(spacing: 12) { @@ -1558,12 +1602,12 @@ struct SettingsView: View { .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } - + private var closeButtonView: some View { HStack{ Spacer() Button(action: { - EventLogger.log(event: "tap_settings_close") + AnalyticsManager.shared.track(.settingsClosed) dismiss() }, label: { Text(String(localized: "settings_view_exit")) @@ -1578,7 +1622,7 @@ struct SettingsView: View { theme.currentTheme.secondaryBGColor VStack { Button(action: { - EventLogger.log(event: "tap_show_special_thanks") + AnalyticsManager.shared.track(.specialThanksViewed) withAnimation{ showSpecialThanks.toggle() } @@ -1753,7 +1797,7 @@ struct SettingsView: View { ZStack { theme.currentTheme.secondaryBGColor Button(action: { - EventLogger.log(event: "tap_show_onboarding") + AnalyticsManager.shared.track(.onboardingReshown) showOnboarding.toggle() }, label: { Text(String(localized: "settings_view_show_onboarding")) @@ -1769,7 +1813,7 @@ struct SettingsView: View { ZStack { theme.currentTheme.secondaryBGColor Button(action: { - EventLogger.log(event: "show_eula") + AnalyticsManager.shared.track(.eulaViewed) openURL(URL(string: "https://feels.app/eula.html")!) }, label: { Text(String(localized: "settings_view_show_eula")) @@ -1785,7 +1829,7 @@ struct SettingsView: View { ZStack { theme.currentTheme.secondaryBGColor Button(action: { - EventLogger.log(event: "show_privacy") + AnalyticsManager.shared.track(.privacyPolicyViewed) openURL(URL(string: "https://feels.app/privacy.html")!) }, label: { Text(String(localized: "settings_view_show_privacy")) @@ -1796,7 +1840,47 @@ struct SettingsView: View { .fixedSize(horizontal: false, vertical: true) .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) } - + + private var analyticsToggle: some View { + ZStack { + theme.currentTheme.secondaryBGColor + HStack(spacing: 12) { + Image(systemName: "chart.bar.xaxis") + .font(.title2) + .foregroundColor(.accentColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text("Share Analytics") + .foregroundColor(textColor) + + Text("Help improve Feels by sharing anonymous usage data") + .font(.caption) + .foregroundStyle(.secondary) + } + + Spacer() + + Toggle("", isOn: Binding( + get: { !AnalyticsManager.shared.isOptedOut }, + set: { enabled in + if enabled { + AnalyticsManager.shared.optIn() + } else { + AnalyticsManager.shared.optOut() + } + } + )) + .labelsHidden() + .accessibilityLabel("Share Analytics") + .accessibilityHint("Toggle anonymous usage analytics") + } + .padding() + } + .fixedSize(horizontal: false, vertical: true) + .cornerRadius(Constants.viewsCornerRaidus, corners: [.topLeft, .topRight, .bottomLeft, .bottomRight]) + } + private var canDelete: some View { ZStack { theme.currentTheme.secondaryBGColor @@ -1804,7 +1888,7 @@ struct SettingsView: View { Toggle(String(localized: "settings_use_delete_enable"), isOn: $deleteEnabled) .onChange(of: deleteEnabled) { _, newValue in - EventLogger.log(event: "toggle_can_delete", withData: ["value": newValue]) + AnalyticsManager.shared.track(.deleteToggleChanged(enabled: newValue)) } .foregroundColor(textColor) .padding() @@ -1819,7 +1903,7 @@ struct SettingsView: View { theme.currentTheme.secondaryBGColor Button(action: { showingExporter.toggle() - EventLogger.log(event: "export_data", withData: ["title": "default"]) + AnalyticsManager.shared.track(.exportTapped) }, label: { Text("Export") .foregroundColor(textColor) @@ -1835,7 +1919,7 @@ struct SettingsView: View { theme.currentTheme.secondaryBGColor Button(action: { showingImporter.toggle() - EventLogger.log(event: "import_data", withData: ["title": "default"]) + AnalyticsManager.shared.track(.importTapped) }, label: { Text("Import") .foregroundColor(textColor) diff --git a/Shared/Views/SwitchableView.swift b/Shared/Views/SwitchableView.swift index 5efcfaa..0207372 100644 --- a/Shared/Views/SwitchableView.swift +++ b/Shared/Views/SwitchableView.swift @@ -98,8 +98,7 @@ struct SwitchableView: View { .onTapGesture { viewType = viewType.next() self.headerTypeChanged(viewType) - EventLogger.log(event: "switchable_view_header_changed", - withData: ["view_type_id": viewType.rawValue, "days_back": daysBack]) + AnalyticsManager.shared.track(.viewHeaderChanged(header: String(describing: viewType))) } } } diff --git a/Shared/Views/YearView/YearView.swift b/Shared/Views/YearView/YearView.swift index 6522cb9..ca6fe0a 100644 --- a/Shared/Views/YearView/YearView.swift +++ b/Shared/Views/YearView/YearView.swift @@ -273,7 +273,7 @@ struct YearView: View { } } .sheet(isPresented: $showSubscriptionStore) { - FeelsSubscriptionStoreView() + FeelsSubscriptionStoreView(source: "year_gate") } .sheet(item: $sharePickerData) { data in SharingStylePickerView(title: data.title, designs: data.designs)