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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user