Replace EventLogger with typed AnalyticsManager using PostHog

Complete analytics overhaul: delete EventLogger.swift, create Analytics.swift
with typed event enum (~45 events), screen tracking, super properties
(theme, icon pack, voting layout, etc.), session replay with kill switch,
autocapture, and network telemetry. Replace all 99 call sites across 38 files
with compiler-enforced typed events in object_action naming convention.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-02-10 15:12:33 -06:00
parent a08d0d33c0
commit e0330dbc8d
38 changed files with 1048 additions and 202 deletions

View File

@@ -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 */,

625
Shared/Analytics.swift Normal file
View File

@@ -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))
}
}

View File

@@ -28,8 +28,6 @@ class AppDelegate: NSObject, UIApplicationDelegate {
UITabBar.appearance().standardAppearance = appearance
UITabBar.appearance().scrollEdgeAppearance = appearance
EventLogger.log(event: "app_launced")
return true
}

View File

@@ -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
}
}

View File

@@ -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")
}
}
}

View File

@@ -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

View File

@@ -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<Void, Error> {
Task.detached { [weak self] in
for await result in Transaction.updates {

View File

@@ -21,10 +21,10 @@ class LocalNotification {
public class func testIfEnabled(completion: @escaping (Result<Bool, Error>) -> 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))
}
}

View File

@@ -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))
}
}
}

View File

@@ -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")
}
}
}

View File

@@ -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"))

View File

@@ -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])
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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()
}

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()
// }
}

View File

@@ -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(

View File

@@ -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))
}
}

View File

@@ -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)

View File

@@ -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()

View File

@@ -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")
}
}
}

View File

@@ -53,7 +53,7 @@ struct IAPWarningView: View {
.padding()
.background(theme.currentTheme.secondaryBGColor)
.sheet(isPresented: $showSubscriptionStore) {
FeelsSubscriptionStoreView()
FeelsSubscriptionStoreView(source: "iap_warning")
}
}
}

View File

@@ -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)

View File

@@ -77,7 +77,7 @@ struct MonthDetailView: View {
)
}
.onAppear(perform: {
EventLogger.log(event: "show_month_detail_view")
AnalyticsManager.shared.trackScreen(.monthDetail)
})
.background(
theme.currentTheme.bg

View File

@@ -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(

View File

@@ -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"))

View File

@@ -38,7 +38,7 @@ struct PaywallPreviewSettingsView: View {
}
}
.sheet(isPresented: $showFullPreview) {
FeelsSubscriptionStoreView(style: selectedStyle)
FeelsSubscriptionStoreView(source: "paywall_preview", style: selectedStyle)
.environmentObject(iapManager)
}
}

View File

@@ -73,7 +73,7 @@ struct SettingsTabView: View {
WhyUpgradeView()
}
.sheet(isPresented: $showSubscriptionStore) {
FeelsSubscriptionStoreView()
FeelsSubscriptionStoreView(source: "settings_tab")
.environmentObject(iapManager)
}
}

View File

@@ -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)

View File

@@ -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)))
}
}
}

View File

@@ -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)