feat: add PostHog analytics with full event tracking across app
Integrate self-hosted PostHog (SPM) with AnalyticsManager singleton wrapping all SDK calls. Adds ~40 type-safe events covering trip planning, schedule, progress, IAP, settings, polls, export, and share flows. Includes session replay, autocapture, network telemetry, privacy opt-out toggle in Settings, and super properties (app version, device, pro status, selected sports). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
40
CLAUDE.md
40
CLAUDE.md
@@ -137,6 +137,46 @@ TripCreationView → TripCreationViewModel → PlanningRequest
|
|||||||
- CloudKit container ID: `iCloud.com.88oakapps.SportsTime`
|
- CloudKit container ID: `iCloud.com.88oakapps.SportsTime`
|
||||||
- `PDFGenerator` and `ExportService` are `@MainActor final class` (not actors) because they access MainActor-isolated UI properties and use UIKit drawing
|
- `PDFGenerator` and `ExportService` are `@MainActor final class` (not actors) because they access MainActor-isolated UI properties and use UIKit drawing
|
||||||
|
|
||||||
|
### Analytics (PostHog)
|
||||||
|
|
||||||
|
All analytics go through `AnalyticsManager.shared` — never call PostHog SDK directly.
|
||||||
|
|
||||||
|
**Architecture** (`Core/Analytics/`):
|
||||||
|
- `AnalyticsManager.swift` - `@MainActor` singleton wrapping PostHogSDK. Handles init, tracking, opt-in/out, super properties, session replay.
|
||||||
|
- `AnalyticsEvent.swift` - Type-safe enum with ~40 event cases, each with `name: String` and `properties: [String: Any]`.
|
||||||
|
|
||||||
|
**Self-hosted backend:** `https://analytics.88oakapps.com`
|
||||||
|
**API key:** Set in `AnalyticsManager.apiKey` (replace placeholder before shipping)
|
||||||
|
|
||||||
|
**Features enabled:**
|
||||||
|
- Event capture + autocapture
|
||||||
|
- Session replay (`screenshotMode` for SwiftUI, text inputs masked)
|
||||||
|
- Network telemetry capture
|
||||||
|
- Super properties (app version, device model, OS, pro status, selected sports)
|
||||||
|
- Privacy opt-out toggle in Settings (persisted via UserDefaults `"analyticsOptedOut"`)
|
||||||
|
|
||||||
|
**Adding new analytics:**
|
||||||
|
```swift
|
||||||
|
// 1. Add case to AnalyticsEvent enum
|
||||||
|
case myNewEvent(param: String)
|
||||||
|
|
||||||
|
// 2. Add name and properties in the computed properties
|
||||||
|
// 3. Call from anywhere:
|
||||||
|
AnalyticsManager.shared.track(.myNewEvent(param: "value"))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Correct Usage:**
|
||||||
|
```swift
|
||||||
|
// ✅ CORRECT - Use AnalyticsManager
|
||||||
|
AnalyticsManager.shared.track(.tripSaved(tripId: id, stopCount: 3, gameCount: 5))
|
||||||
|
AnalyticsManager.shared.trackScreen("TripDetail")
|
||||||
|
|
||||||
|
// ❌ WRONG - Never call PostHog SDK directly
|
||||||
|
PostHogSDK.shared.capture("trip_saved") // NO!
|
||||||
|
```
|
||||||
|
|
||||||
|
**Initialization:** Called during app bootstrap in `SportsTimeApp.performBootstrap()` (Step 7). Super properties refreshed on `.active` scene phase. Flushed on `.background`.
|
||||||
|
|
||||||
### Themed Background System
|
### Themed Background System
|
||||||
|
|
||||||
All views use `.themedBackground()` modifier for consistent backgrounds app-wide.
|
All views use `.themedBackground()` modifier for consistent backgrounds app-wide.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1CC750E42F148BA4007D870A /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 1CC750E32F148BA4007D870A /* SwiftSoup */; };
|
1CC750E42F148BA4007D870A /* SwiftSoup in Frameworks */ = {isa = PBXBuildFile; productRef = 1CC750E32F148BA4007D870A /* SwiftSoup */; };
|
||||||
|
1CC750E72F15A1B0007D870A /* PostHog in Frameworks */ = {isa = PBXBuildFile; productRef = 1CC750E62F15A1B0007D870A /* PostHog */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -70,6 +71,7 @@
|
|||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
1CC750E42F148BA4007D870A /* SwiftSoup in Frameworks */,
|
1CC750E42F148BA4007D870A /* SwiftSoup in Frameworks */,
|
||||||
|
1CC750E72F15A1B0007D870A /* PostHog in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -139,6 +141,7 @@
|
|||||||
name = SportsTime;
|
name = SportsTime;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
1CC750E32F148BA4007D870A /* SwiftSoup */,
|
1CC750E32F148BA4007D870A /* SwiftSoup */,
|
||||||
|
1CC750E62F15A1B0007D870A /* PostHog */,
|
||||||
);
|
);
|
||||||
productName = SportsTime;
|
productName = SportsTime;
|
||||||
productReference = 1CA7F8F32F0D647100490ABD /* SportsTime.app */;
|
productReference = 1CA7F8F32F0D647100490ABD /* SportsTime.app */;
|
||||||
@@ -224,6 +227,7 @@
|
|||||||
minimizedProjectReferenceProxies = 1;
|
minimizedProjectReferenceProxies = 1;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
1CC750E12F1487A8007D870A /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
1CC750E12F1487A8007D870A /* XCRemoteSwiftPackageReference "SwiftSoup" */,
|
||||||
|
1CC750E52F15A1B0007D870A /* XCRemoteSwiftPackageReference "posthog-ios" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
productRefGroup = 1CA7F8F42F0D647100490ABD /* Products */;
|
productRefGroup = 1CA7F8F42F0D647100490ABD /* Products */;
|
||||||
@@ -626,6 +630,14 @@
|
|||||||
minimumVersion = 2.11.3;
|
minimumVersion = 2.11.3;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
1CC750E52F15A1B0007D870A /* XCRemoteSwiftPackageReference "posthog-ios" */ = {
|
||||||
|
isa = XCRemoteSwiftPackageReference;
|
||||||
|
repositoryURL = "https://github.com/PostHog/posthog-ios.git";
|
||||||
|
requirement = {
|
||||||
|
kind = upToNextMajorVersion;
|
||||||
|
minimumVersion = 3.0.0;
|
||||||
|
};
|
||||||
|
};
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
/* End XCRemoteSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
@@ -634,6 +646,11 @@
|
|||||||
package = 1CC750E12F1487A8007D870A /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
package = 1CC750E12F1487A8007D870A /* XCRemoteSwiftPackageReference "SwiftSoup" */;
|
||||||
productName = SwiftSoup;
|
productName = SwiftSoup;
|
||||||
};
|
};
|
||||||
|
1CC750E62F15A1B0007D870A /* PostHog */ = {
|
||||||
|
isa = XCSwiftPackageProductDependency;
|
||||||
|
package = 1CC750E52F15A1B0007D870A /* XCRemoteSwiftPackageReference "posthog-ios" */;
|
||||||
|
productName = PostHog;
|
||||||
|
};
|
||||||
/* End XCSwiftPackageProductDependency section */
|
/* End XCSwiftPackageProductDependency section */
|
||||||
};
|
};
|
||||||
rootObject = 1CA7F8EB2F0D647100490ABD /* Project object */;
|
rootObject = 1CA7F8EB2F0D647100490ABD /* Project object */;
|
||||||
|
|||||||
258
SportsTime/Core/Analytics/AnalyticsEvent.swift
Normal file
258
SportsTime/Core/Analytics/AnalyticsEvent.swift
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
//
|
||||||
|
// AnalyticsEvent.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Type-safe analytics event definitions.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum AnalyticsEvent {
|
||||||
|
|
||||||
|
// MARK: - Navigation
|
||||||
|
|
||||||
|
case tabSwitched(tab: String, previousTab: String?)
|
||||||
|
case screenViewed(screen: String)
|
||||||
|
|
||||||
|
// MARK: - Trip Planning
|
||||||
|
|
||||||
|
case tripWizardStarted(mode: String)
|
||||||
|
case tripWizardStepCompleted(step: String, mode: String)
|
||||||
|
case tripPlanned(sportCount: Int, stopCount: Int, dayCount: Int, mode: String)
|
||||||
|
case tripPlanFailed(mode: String, error: String)
|
||||||
|
case tripSaved(tripId: String, stopCount: Int, gameCount: Int)
|
||||||
|
case tripDeleted(tripId: String)
|
||||||
|
case tripViewed(tripId: String, source: String)
|
||||||
|
case suggestedTripTapped(region: String, stopCount: Int)
|
||||||
|
|
||||||
|
// MARK: - Schedule
|
||||||
|
|
||||||
|
case scheduleViewed(sports: [String])
|
||||||
|
case scheduleFiltered(sport: String, dateRange: String)
|
||||||
|
case gameTapped(gameId: String, sport: String, homeTeam: String, awayTeam: String)
|
||||||
|
|
||||||
|
// MARK: - Progress
|
||||||
|
|
||||||
|
case stadiumVisitAdded(stadiumId: String, sport: String)
|
||||||
|
case stadiumVisitDeleted(stadiumId: String, sport: String)
|
||||||
|
case progressCardShared(sport: String)
|
||||||
|
case sportSwitched(sport: String)
|
||||||
|
|
||||||
|
// MARK: - Export
|
||||||
|
|
||||||
|
case pdfExportStarted(tripId: String, stopCount: Int)
|
||||||
|
case pdfExportCompleted(tripId: String)
|
||||||
|
case pdfExportFailed(tripId: String, error: String)
|
||||||
|
case tripShared(tripId: String)
|
||||||
|
|
||||||
|
// MARK: - IAP
|
||||||
|
|
||||||
|
case paywallViewed(source: String)
|
||||||
|
case purchaseStarted(productId: String)
|
||||||
|
case purchaseCompleted(productId: String)
|
||||||
|
case purchaseFailed(productId: String, error: String)
|
||||||
|
case purchaseRestored
|
||||||
|
case subscriptionStatusChanged(isPro: Bool, plan: String?)
|
||||||
|
|
||||||
|
// MARK: - Settings
|
||||||
|
|
||||||
|
case themeChanged(from: String, to: String)
|
||||||
|
case appearanceChanged(mode: String)
|
||||||
|
case sportToggled(sport: String, enabled: Bool)
|
||||||
|
case animationsToggled(enabled: Bool)
|
||||||
|
case drivingHoursChanged(hours: Int)
|
||||||
|
case analyticsToggled(enabled: Bool)
|
||||||
|
case settingsReset
|
||||||
|
|
||||||
|
// MARK: - Polls
|
||||||
|
|
||||||
|
case pollCreated(optionCount: Int)
|
||||||
|
case pollVoted(pollId: String)
|
||||||
|
case pollShared(pollId: String)
|
||||||
|
|
||||||
|
// MARK: - Onboarding
|
||||||
|
|
||||||
|
case onboardingPaywallViewed
|
||||||
|
case onboardingPaywallDismissed
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
case errorOccurred(domain: String, message: String, screen: String?)
|
||||||
|
|
||||||
|
// MARK: - Computed Properties
|
||||||
|
|
||||||
|
var name: String {
|
||||||
|
switch self {
|
||||||
|
case .tabSwitched: return "tab_switched"
|
||||||
|
case .screenViewed: return "screen_viewed"
|
||||||
|
case .tripWizardStarted: return "trip_wizard_started"
|
||||||
|
case .tripWizardStepCompleted: return "trip_wizard_step_completed"
|
||||||
|
case .tripPlanned: return "trip_planned"
|
||||||
|
case .tripPlanFailed: return "trip_plan_failed"
|
||||||
|
case .tripSaved: return "trip_saved"
|
||||||
|
case .tripDeleted: return "trip_deleted"
|
||||||
|
case .tripViewed: return "trip_viewed"
|
||||||
|
case .suggestedTripTapped: return "suggested_trip_tapped"
|
||||||
|
case .scheduleViewed: return "schedule_viewed"
|
||||||
|
case .scheduleFiltered: return "schedule_filtered"
|
||||||
|
case .gameTapped: return "game_tapped"
|
||||||
|
case .stadiumVisitAdded: return "stadium_visit_added"
|
||||||
|
case .stadiumVisitDeleted: return "stadium_visit_deleted"
|
||||||
|
case .progressCardShared: return "progress_card_shared"
|
||||||
|
case .sportSwitched: return "sport_switched"
|
||||||
|
case .pdfExportStarted: return "pdf_export_started"
|
||||||
|
case .pdfExportCompleted: return "pdf_export_completed"
|
||||||
|
case .pdfExportFailed: return "pdf_export_failed"
|
||||||
|
case .tripShared: return "trip_shared"
|
||||||
|
case .paywallViewed: return "paywall_viewed"
|
||||||
|
case .purchaseStarted: return "purchase_started"
|
||||||
|
case .purchaseCompleted: return "purchase_completed"
|
||||||
|
case .purchaseFailed: return "purchase_failed"
|
||||||
|
case .purchaseRestored: return "purchase_restored"
|
||||||
|
case .subscriptionStatusChanged: return "subscription_status_changed"
|
||||||
|
case .themeChanged: return "theme_changed"
|
||||||
|
case .appearanceChanged: return "appearance_changed"
|
||||||
|
case .sportToggled: return "sport_toggled"
|
||||||
|
case .animationsToggled: return "animations_toggled"
|
||||||
|
case .drivingHoursChanged: return "driving_hours_changed"
|
||||||
|
case .analyticsToggled: return "analytics_toggled"
|
||||||
|
case .settingsReset: return "settings_reset"
|
||||||
|
case .pollCreated: return "poll_created"
|
||||||
|
case .pollVoted: return "poll_voted"
|
||||||
|
case .pollShared: return "poll_shared"
|
||||||
|
case .onboardingPaywallViewed: return "onboarding_paywall_viewed"
|
||||||
|
case .onboardingPaywallDismissed: return "onboarding_paywall_dismissed"
|
||||||
|
case .errorOccurred: return "error_occurred"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var properties: [String: Any] {
|
||||||
|
switch self {
|
||||||
|
case .tabSwitched(let tab, let previousTab):
|
||||||
|
var props: [String: Any] = ["tab_name": tab]
|
||||||
|
if let prev = previousTab { props["previous_tab"] = prev }
|
||||||
|
return props
|
||||||
|
|
||||||
|
case .screenViewed(let screen):
|
||||||
|
return ["screen_name": screen]
|
||||||
|
|
||||||
|
case .tripWizardStarted(let mode):
|
||||||
|
return ["mode": mode]
|
||||||
|
|
||||||
|
case .tripWizardStepCompleted(let step, let mode):
|
||||||
|
return ["step_name": step, "mode": mode]
|
||||||
|
|
||||||
|
case .tripPlanned(let sportCount, let stopCount, let dayCount, let mode):
|
||||||
|
return ["sport_count": sportCount, "stop_count": stopCount, "day_count": dayCount, "mode": mode]
|
||||||
|
|
||||||
|
case .tripPlanFailed(let mode, let error):
|
||||||
|
return ["mode": mode, "error": error]
|
||||||
|
|
||||||
|
case .tripSaved(let tripId, let stopCount, let gameCount):
|
||||||
|
return ["trip_id": tripId, "stop_count": stopCount, "game_count": gameCount]
|
||||||
|
|
||||||
|
case .tripDeleted(let tripId):
|
||||||
|
return ["trip_id": tripId]
|
||||||
|
|
||||||
|
case .tripViewed(let tripId, let source):
|
||||||
|
return ["trip_id": tripId, "source": source]
|
||||||
|
|
||||||
|
case .suggestedTripTapped(let region, let stopCount):
|
||||||
|
return ["region": region, "stop_count": stopCount]
|
||||||
|
|
||||||
|
case .scheduleViewed(let sports):
|
||||||
|
return ["sports": sports]
|
||||||
|
|
||||||
|
case .scheduleFiltered(let sport, let dateRange):
|
||||||
|
return ["sport": sport, "date_range": dateRange]
|
||||||
|
|
||||||
|
case .gameTapped(let gameId, let sport, let homeTeam, let awayTeam):
|
||||||
|
return ["game_id": gameId, "sport": sport, "home_team": homeTeam, "away_team": awayTeam]
|
||||||
|
|
||||||
|
case .stadiumVisitAdded(let stadiumId, let sport):
|
||||||
|
return ["stadium_id": stadiumId, "sport": sport]
|
||||||
|
|
||||||
|
case .stadiumVisitDeleted(let stadiumId, let sport):
|
||||||
|
return ["stadium_id": stadiumId, "sport": sport]
|
||||||
|
|
||||||
|
case .progressCardShared(let sport):
|
||||||
|
return ["sport": sport]
|
||||||
|
|
||||||
|
case .sportSwitched(let sport):
|
||||||
|
return ["sport": sport]
|
||||||
|
|
||||||
|
case .pdfExportStarted(let tripId, let stopCount):
|
||||||
|
return ["trip_id": tripId, "stop_count": stopCount]
|
||||||
|
|
||||||
|
case .pdfExportCompleted(let tripId):
|
||||||
|
return ["trip_id": tripId]
|
||||||
|
|
||||||
|
case .pdfExportFailed(let tripId, let error):
|
||||||
|
return ["trip_id": tripId, "error": error]
|
||||||
|
|
||||||
|
case .tripShared(let tripId):
|
||||||
|
return ["trip_id": tripId]
|
||||||
|
|
||||||
|
case .paywallViewed(let source):
|
||||||
|
return ["source": source]
|
||||||
|
|
||||||
|
case .purchaseStarted(let productId):
|
||||||
|
return ["product_id": productId]
|
||||||
|
|
||||||
|
case .purchaseCompleted(let productId):
|
||||||
|
return ["product_id": productId]
|
||||||
|
|
||||||
|
case .purchaseFailed(let productId, let error):
|
||||||
|
return ["product_id": productId, "error": error]
|
||||||
|
|
||||||
|
case .purchaseRestored:
|
||||||
|
return [:]
|
||||||
|
|
||||||
|
case .subscriptionStatusChanged(let isPro, let plan):
|
||||||
|
var props: [String: Any] = ["is_pro": isPro]
|
||||||
|
if let plan { props["plan"] = plan }
|
||||||
|
return props
|
||||||
|
|
||||||
|
case .themeChanged(let from, let to):
|
||||||
|
return ["from": from, "to": to]
|
||||||
|
|
||||||
|
case .appearanceChanged(let mode):
|
||||||
|
return ["mode": mode]
|
||||||
|
|
||||||
|
case .sportToggled(let sport, let enabled):
|
||||||
|
return ["sport": sport, "enabled": enabled]
|
||||||
|
|
||||||
|
case .animationsToggled(let enabled):
|
||||||
|
return ["enabled": enabled]
|
||||||
|
|
||||||
|
case .drivingHoursChanged(let hours):
|
||||||
|
return ["hours": hours]
|
||||||
|
|
||||||
|
case .analyticsToggled(let enabled):
|
||||||
|
return ["enabled": enabled]
|
||||||
|
|
||||||
|
case .settingsReset:
|
||||||
|
return [:]
|
||||||
|
|
||||||
|
case .pollCreated(let optionCount):
|
||||||
|
return ["option_count": optionCount]
|
||||||
|
|
||||||
|
case .pollVoted(let pollId):
|
||||||
|
return ["poll_id": pollId]
|
||||||
|
|
||||||
|
case .pollShared(let pollId):
|
||||||
|
return ["poll_id": pollId]
|
||||||
|
|
||||||
|
case .onboardingPaywallViewed:
|
||||||
|
return [:]
|
||||||
|
|
||||||
|
case .onboardingPaywallDismissed:
|
||||||
|
return [:]
|
||||||
|
|
||||||
|
case .errorOccurred(let domain, let message, let screen):
|
||||||
|
var props: [String: Any] = ["domain": domain, "message": message]
|
||||||
|
if let screen { props["screen"] = screen }
|
||||||
|
return props
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
300
SportsTime/Core/Analytics/AnalyticsManager.swift
Normal file
300
SportsTime/Core/Analytics/AnalyticsManager.swift
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
//
|
||||||
|
// AnalyticsManager.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Singleton analytics manager wrapping PostHog SDK.
|
||||||
|
// All analytics events flow through this single manager.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import PostHog
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AnalyticsManager {
|
||||||
|
|
||||||
|
// MARK: - Singleton
|
||||||
|
|
||||||
|
static let shared = AnalyticsManager()
|
||||||
|
|
||||||
|
// MARK: - Configuration
|
||||||
|
|
||||||
|
private static let apiKey = "phc_SportsTime_production"
|
||||||
|
private static let host = "https://analytics.88oakapps.com"
|
||||||
|
private static let optOutKey = "analyticsOptedOut"
|
||||||
|
private static let sessionReplayKey = "analytics_session_replay_enabled"
|
||||||
|
private static let iso8601Formatter = ISO8601DateFormatter()
|
||||||
|
|
||||||
|
// MARK: - State
|
||||||
|
|
||||||
|
var isOptedOut: Bool {
|
||||||
|
UserDefaults.standard.bool(forKey: Self.optOutKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sessionReplayEnabled: Bool {
|
||||||
|
get {
|
||||||
|
if UserDefaults.standard.object(forKey: Self.sessionReplayKey) == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return UserDefaults.standard.bool(forKey: Self.sessionReplayKey)
|
||||||
|
}
|
||||||
|
set {
|
||||||
|
UserDefaults.standard.set(newValue, forKey: Self.sessionReplayKey)
|
||||||
|
if newValue {
|
||||||
|
PostHogSDK.shared.startSessionRecording()
|
||||||
|
} else {
|
||||||
|
PostHogSDK.shared.stopSessionRecording()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var isConfigured = false
|
||||||
|
|
||||||
|
// MARK: - Initialization
|
||||||
|
|
||||||
|
private init() {}
|
||||||
|
|
||||||
|
// MARK: - Setup
|
||||||
|
|
||||||
|
func configure() {
|
||||||
|
guard !isConfigured else { return }
|
||||||
|
|
||||||
|
let config = PostHogConfig(apiKey: Self.apiKey, host: Self.host)
|
||||||
|
|
||||||
|
// Auto-capture
|
||||||
|
config.captureElementInteractions = true
|
||||||
|
config.captureApplicationLifecycleEvents = true
|
||||||
|
config.captureScreenViews = true
|
||||||
|
|
||||||
|
// Session replay
|
||||||
|
config.sessionReplay = sessionReplayEnabled
|
||||||
|
config.sessionReplayConfig.maskAllTextInputs = true
|
||||||
|
config.sessionReplayConfig.maskAllImages = false
|
||||||
|
config.sessionReplayConfig.captureNetworkTelemetry = true
|
||||||
|
config.sessionReplayConfig.screenshotMode = true // Required for SwiftUI
|
||||||
|
|
||||||
|
// Respect user opt-out preference
|
||||||
|
if isOptedOut {
|
||||||
|
config.optOut = true
|
||||||
|
}
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
config.debug = true
|
||||||
|
config.flushAt = 1
|
||||||
|
#endif
|
||||||
|
|
||||||
|
PostHogSDK.shared.setup(config)
|
||||||
|
isConfigured = true
|
||||||
|
|
||||||
|
// Register super properties
|
||||||
|
registerSuperProperties()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Super Properties
|
||||||
|
|
||||||
|
func registerSuperProperties() {
|
||||||
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
|
||||||
|
let build = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "unknown"
|
||||||
|
let device = UIDevice.current.model
|
||||||
|
let osVersion = UIDevice.current.systemVersion
|
||||||
|
let isPro = StoreManager.shared.isPro
|
||||||
|
let animationsEnabled = DesignStyleManager.shared.animationsEnabled
|
||||||
|
|
||||||
|
// Load selected sports from UserDefaults
|
||||||
|
let selectedSports = UserDefaults.standard.stringArray(forKey: "selectedSports") ?? Sport.supported.map(\.rawValue)
|
||||||
|
|
||||||
|
// Keep super-property keys aligned with Feels so dashboards can compare apps 1:1.
|
||||||
|
PostHogSDK.shared.register([
|
||||||
|
"app_version": version,
|
||||||
|
"build_number": build,
|
||||||
|
"device_model": device,
|
||||||
|
"os_version": osVersion,
|
||||||
|
"is_pro": isPro,
|
||||||
|
"animations_enabled": animationsEnabled,
|
||||||
|
"selected_sports": selectedSports,
|
||||||
|
"theme": "n/a",
|
||||||
|
"icon_pack": "n/a",
|
||||||
|
"voting_layout": "n/a",
|
||||||
|
"day_view_style": "n/a",
|
||||||
|
"mood_shape": "n/a",
|
||||||
|
"personality_pack": "n/a",
|
||||||
|
"privacy_lock_enabled": false,
|
||||||
|
"healthkit_enabled": false,
|
||||||
|
"days_filter_count": 0,
|
||||||
|
"days_filter_all": false,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSuperProperties() {
|
||||||
|
registerSuperProperties()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Event Tracking
|
||||||
|
|
||||||
|
func track(_ event: AnalyticsEvent) {
|
||||||
|
guard isConfigured else { return }
|
||||||
|
PostHogSDK.shared.capture(event.name, properties: event.properties)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Screen Tracking (manual supplement to auto-capture)
|
||||||
|
|
||||||
|
func trackScreen(_ screenName: String, properties: [String: Any]? = nil) {
|
||||||
|
guard isConfigured else { return }
|
||||||
|
var props: [String: Any] = ["screen_name": screenName]
|
||||||
|
if let properties { props.merge(properties) { _, new in new } }
|
||||||
|
|
||||||
|
#if DEBUG
|
||||||
|
print("[Analytics] screen_viewed: \(screenName)")
|
||||||
|
#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: - SwiftUI Screen Tracking Modifier
|
||||||
|
|
||||||
|
struct ScreenTrackingModifier: ViewModifier {
|
||||||
|
let screenName: String
|
||||||
|
let properties: [String: Any]?
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content.onAppear {
|
||||||
|
AnalyticsManager.shared.trackScreen(screenName, properties: properties)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension View {
|
||||||
|
func trackScreen(_ screenName: String, properties: [String: Any]? = nil) -> some View {
|
||||||
|
modifier(ScreenTrackingModifier(screenName: screenName, properties: properties))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ final class StoreManager {
|
|||||||
private(set) var isLoading = false
|
private(set) var isLoading = false
|
||||||
private(set) var error: StoreError?
|
private(set) var error: StoreError?
|
||||||
|
|
||||||
|
/// Current subscription status details (nil if no subscription)
|
||||||
|
private(set) var subscriptionStatus: SubscriptionStatusInfo?
|
||||||
|
|
||||||
// MARK: - Debug Override (DEBUG builds only)
|
// MARK: - Debug Override (DEBUG builds only)
|
||||||
|
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
@@ -55,6 +58,10 @@ final class StoreManager {
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
if debugProOverride { return true }
|
if debugProOverride { return true }
|
||||||
#endif
|
#endif
|
||||||
|
// Grant access if subscribed OR in grace period (billing retry)
|
||||||
|
if let status = subscriptionStatus, status.isInGracePeriod {
|
||||||
|
return true
|
||||||
|
}
|
||||||
return !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty
|
return !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,13 +83,37 @@ final class StoreManager {
|
|||||||
isLoading = true
|
isLoading = true
|
||||||
error = nil
|
error = nil
|
||||||
|
|
||||||
|
print("[StoreManager] Loading products for IDs: \(Self.proProductIDs)")
|
||||||
|
|
||||||
do {
|
do {
|
||||||
products = try await Product.products(for: Self.proProductIDs)
|
let fetchedProducts = try await Product.products(for: Self.proProductIDs)
|
||||||
isLoading = false
|
products = fetchedProducts
|
||||||
|
|
||||||
|
print("[StoreManager] Loaded \(fetchedProducts.count) products:")
|
||||||
|
for product in fetchedProducts {
|
||||||
|
print("[StoreManager] - \(product.id): \(product.displayPrice) (\(product.displayName))")
|
||||||
|
if let sub = product.subscription {
|
||||||
|
print("[StoreManager] Subscription period: \(sub.subscriptionPeriod)")
|
||||||
|
if let intro = sub.introductoryOffer {
|
||||||
|
print("[StoreManager] Intro offer: \(intro.paymentMode) for \(intro.period), price: \(intro.displayPrice)")
|
||||||
|
} else {
|
||||||
|
print("[StoreManager] No intro offer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat an empty fetch as a configuration issue so UI can show a useful fallback.
|
||||||
|
if fetchedProducts.isEmpty {
|
||||||
|
print("[StoreManager] WARNING: No products returned — check Configuration.storekit")
|
||||||
|
error = .productNotFound
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
products = []
|
||||||
self.error = .productNotFound
|
self.error = .productNotFound
|
||||||
isLoading = false
|
print("[StoreManager] ERROR loading products: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Entitlement Management
|
// MARK: - Entitlement Management
|
||||||
@@ -97,11 +128,73 @@ final class StoreManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
purchasedProductIDs = purchased
|
purchasedProductIDs = purchased
|
||||||
|
|
||||||
|
// Update subscription status details
|
||||||
|
await updateSubscriptionStatus()
|
||||||
|
trackSubscriptionAnalytics(source: "entitlements_refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Subscription Status
|
||||||
|
|
||||||
|
private func updateSubscriptionStatus() async {
|
||||||
|
// Find a Pro product to check subscription status
|
||||||
|
guard let product = products.first(where: { Self.proProductIDs.contains($0.id) }),
|
||||||
|
let subscription = product.subscription else {
|
||||||
|
subscriptionStatus = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let statuses = try await subscription.status
|
||||||
|
guard let status = statuses.first(where: { $0.state != .revoked && $0.state != .expired }) ?? statuses.first else {
|
||||||
|
subscriptionStatus = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let renewalInfo = try? status.renewalInfo.payloadValue
|
||||||
|
let transaction = try? status.transaction.payloadValue
|
||||||
|
|
||||||
|
let planName: String
|
||||||
|
if let productID = transaction?.productID {
|
||||||
|
planName = productID.contains("annual") ? "Annual" : "Monthly"
|
||||||
|
} else {
|
||||||
|
planName = "Pro"
|
||||||
|
}
|
||||||
|
|
||||||
|
let state: SubscriptionState
|
||||||
|
switch status.state {
|
||||||
|
case .subscribed:
|
||||||
|
state = .active
|
||||||
|
case .inBillingRetryPeriod:
|
||||||
|
state = .billingRetry
|
||||||
|
case .inGracePeriod:
|
||||||
|
state = .gracePeriod
|
||||||
|
case .expired:
|
||||||
|
state = .expired
|
||||||
|
case .revoked:
|
||||||
|
state = .revoked
|
||||||
|
default:
|
||||||
|
state = .active
|
||||||
|
}
|
||||||
|
|
||||||
|
subscriptionStatus = SubscriptionStatusInfo(
|
||||||
|
state: state,
|
||||||
|
planName: planName,
|
||||||
|
productID: transaction?.productID,
|
||||||
|
expirationDate: transaction?.expirationDate,
|
||||||
|
gracePeriodExpirationDate: renewalInfo?.gracePeriodExpirationDate,
|
||||||
|
willAutoRenew: renewalInfo?.willAutoRenew ?? false,
|
||||||
|
isInGracePeriod: status.state == .inBillingRetryPeriod || status.state == .inGracePeriod
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
subscriptionStatus = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Purchase
|
// MARK: - Purchase
|
||||||
|
|
||||||
func purchase(_ product: Product) async throws {
|
func purchase(_ product: Product, source: String = "store_manager") async throws {
|
||||||
|
AnalyticsManager.shared.trackPurchaseStarted(productId: product.id, source: source)
|
||||||
let result = try await product.purchase()
|
let result = try await product.purchase()
|
||||||
|
|
||||||
switch result {
|
switch result {
|
||||||
@@ -109,28 +202,91 @@ final class StoreManager {
|
|||||||
let transaction = try checkVerified(verification)
|
let transaction = try checkVerified(verification)
|
||||||
await transaction.finish()
|
await transaction.finish()
|
||||||
await updateEntitlements()
|
await updateEntitlements()
|
||||||
|
AnalyticsManager.shared.trackPurchaseCompleted(productId: product.id, source: source)
|
||||||
|
trackSubscriptionAnalytics(source: "purchase_success")
|
||||||
|
|
||||||
case .userCancelled:
|
case .userCancelled:
|
||||||
|
AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "user_cancelled")
|
||||||
throw StoreError.userCancelled
|
throw StoreError.userCancelled
|
||||||
|
|
||||||
case .pending:
|
case .pending:
|
||||||
// Ask to Buy or SCA - transaction will appear in updates when approved
|
// Ask to Buy or SCA - transaction will appear in updates when approved
|
||||||
break
|
AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "pending")
|
||||||
|
|
||||||
@unknown default:
|
@unknown default:
|
||||||
|
AnalyticsManager.shared.trackPurchaseFailed(productId: product.id, source: source, error: "unknown")
|
||||||
throw StoreError.purchaseFailed
|
throw StoreError.purchaseFailed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Restore
|
// MARK: - Restore
|
||||||
|
|
||||||
func restorePurchases() async {
|
func restorePurchases(source: String = "settings") async {
|
||||||
do {
|
do {
|
||||||
try await AppStore.sync()
|
try await AppStore.sync()
|
||||||
} catch {
|
} catch {
|
||||||
// Sync failed, but we can still check current entitlements
|
// Sync failed, but we can still check current entitlements
|
||||||
|
AnalyticsManager.shared.trackPurchaseFailed(productId: nil, source: source, error: error.localizedDescription)
|
||||||
}
|
}
|
||||||
await updateEntitlements()
|
await updateEntitlements()
|
||||||
|
AnalyticsManager.shared.trackPurchaseRestored(source: source)
|
||||||
|
trackSubscriptionAnalytics(source: "restore")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Analytics
|
||||||
|
|
||||||
|
func trackSubscriptionAnalytics(source: String) {
|
||||||
|
let status: String
|
||||||
|
let isSubscribed: Bool
|
||||||
|
|
||||||
|
if let subscriptionStatus {
|
||||||
|
switch subscriptionStatus.state {
|
||||||
|
case .active:
|
||||||
|
status = "subscribed"
|
||||||
|
isSubscribed = true
|
||||||
|
case .billingRetry:
|
||||||
|
status = "billing_retry"
|
||||||
|
isSubscribed = true
|
||||||
|
case .gracePeriod:
|
||||||
|
status = "grace_period"
|
||||||
|
isSubscribed = true
|
||||||
|
case .expired:
|
||||||
|
status = "expired"
|
||||||
|
isSubscribed = false
|
||||||
|
case .revoked:
|
||||||
|
status = "revoked"
|
||||||
|
isSubscribed = false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status = isPro ? "subscribed" : "free"
|
||||||
|
isSubscribed = isPro
|
||||||
|
}
|
||||||
|
|
||||||
|
let type = subscriptionType(for: subscriptionStatus?.productID)
|
||||||
|
AnalyticsManager.shared.trackSubscriptionStatusObserved(
|
||||||
|
status: status,
|
||||||
|
type: type,
|
||||||
|
source: source,
|
||||||
|
isSubscribed: isSubscribed,
|
||||||
|
hasFullAccess: isPro,
|
||||||
|
productId: subscriptionStatus?.productID,
|
||||||
|
willAutoRenew: subscriptionStatus?.willAutoRenew,
|
||||||
|
isInGracePeriod: subscriptionStatus?.isInGracePeriod,
|
||||||
|
trialDaysRemaining: nil,
|
||||||
|
expirationDate: subscriptionStatus?.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"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Transaction Listener
|
// MARK: - Transaction Listener
|
||||||
@@ -161,3 +317,57 @@ final class StoreManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Subscription Status Info
|
||||||
|
|
||||||
|
enum SubscriptionState: String {
|
||||||
|
case active
|
||||||
|
case billingRetry
|
||||||
|
case gracePeriod
|
||||||
|
case expired
|
||||||
|
case revoked
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .active: return "Active"
|
||||||
|
case .billingRetry: return "Payment Issue"
|
||||||
|
case .gracePeriod: return "Grace Period"
|
||||||
|
case .expired: return "Expired"
|
||||||
|
case .revoked: return "Revoked"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isActive: Bool {
|
||||||
|
self == .active || self == .billingRetry || self == .gracePeriod
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubscriptionStatusInfo {
|
||||||
|
let state: SubscriptionState
|
||||||
|
let planName: String
|
||||||
|
let productID: String?
|
||||||
|
let expirationDate: Date?
|
||||||
|
let gracePeriodExpirationDate: Date?
|
||||||
|
let willAutoRenew: Bool
|
||||||
|
let isInGracePeriod: Bool
|
||||||
|
|
||||||
|
var renewalDescription: String {
|
||||||
|
guard let date = expirationDate else { return "" }
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateStyle = .medium
|
||||||
|
|
||||||
|
if state == .expired || state == .revoked {
|
||||||
|
return "Expired \(formatter.string(from: date))"
|
||||||
|
}
|
||||||
|
|
||||||
|
if isInGracePeriod, let graceDate = gracePeriodExpirationDate {
|
||||||
|
return "Payment due by \(formatter.string(from: graceDate))"
|
||||||
|
}
|
||||||
|
|
||||||
|
if willAutoRenew {
|
||||||
|
return "Renews \(formatter.string(from: date))"
|
||||||
|
} else {
|
||||||
|
return "Expires \(formatter.string(from: date))"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ struct ShareButton<Content: ShareableContent>: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
Button {
|
Button {
|
||||||
showPreview = true
|
showPreview = true
|
||||||
|
if let tripContent = content as? TripShareContent {
|
||||||
|
AnalyticsManager.shared.track(.tripShared(tripId: tripContent.trip.id.uuidString))
|
||||||
|
} else if content is ProgressShareContent {
|
||||||
|
AnalyticsManager.shared.track(.progressCardShared(sport: "unknown"))
|
||||||
|
}
|
||||||
} label: {
|
} label: {
|
||||||
switch style {
|
switch style {
|
||||||
case .icon:
|
case .icon:
|
||||||
|
|||||||
@@ -91,6 +91,13 @@ struct HomeView: View {
|
|||||||
.tag(4)
|
.tag(4)
|
||||||
}
|
}
|
||||||
.tint(Theme.warmOrange)
|
.tint(Theme.warmOrange)
|
||||||
|
.onChange(of: selectedTab) { oldTab, newTab in
|
||||||
|
let tabNames = ["Home", "Schedule", "My Trips", "Progress", "Settings"]
|
||||||
|
let newName = newTab < tabNames.count ? tabNames[newTab] : "Unknown"
|
||||||
|
let oldName = oldTab < tabNames.count ? tabNames[oldTab] : nil
|
||||||
|
AnalyticsManager.shared.track(.tabSwitched(tab: newName, previousTab: oldName))
|
||||||
|
AnalyticsManager.shared.trackScreen(newName)
|
||||||
|
}
|
||||||
.sheet(isPresented: $showNewTrip) {
|
.sheet(isPresented: $showNewTrip) {
|
||||||
TripWizardView()
|
TripWizardView()
|
||||||
.environment(\.isDemoMode, ProcessInfo.isDemoMode)
|
.environment(\.isDemoMode, ProcessInfo.isDemoMode)
|
||||||
@@ -106,6 +113,9 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
let tabNames = ["Home", "Schedule", "My Trips", "Progress", "Settings"]
|
||||||
|
let activeTabName = selectedTab < tabNames.count ? tabNames[selectedTab] : "Unknown"
|
||||||
|
AnalyticsManager.shared.trackScreen(activeTabName)
|
||||||
if displayedTips.isEmpty {
|
if displayedTips.isEmpty {
|
||||||
displayedTips = PlanningTips.random(3)
|
displayedTips = PlanningTips.random(3)
|
||||||
}
|
}
|
||||||
@@ -116,7 +126,7 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showProPaywall) {
|
.sheet(isPresented: $showProPaywall) {
|
||||||
PaywallView()
|
PaywallView(source: "home_progress_gate")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +219,10 @@ struct HomeView: View {
|
|||||||
HStack(spacing: Theme.Spacing.md) {
|
HStack(spacing: Theme.Spacing.md) {
|
||||||
ForEach(regionGroup.trips) { suggestedTrip in
|
ForEach(regionGroup.trips) { suggestedTrip in
|
||||||
Button {
|
Button {
|
||||||
|
AnalyticsManager.shared.track(.suggestedTripTapped(
|
||||||
|
region: regionGroup.region.shortName,
|
||||||
|
stopCount: suggestedTrip.trip.stops.count
|
||||||
|
))
|
||||||
selectedSuggestedTrip = suggestedTrip
|
selectedSuggestedTrip = suggestedTrip
|
||||||
} label: {
|
} label: {
|
||||||
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
SuggestedTripCard(suggestedTrip: suggestedTrip)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ struct ProGateModifier: ViewModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPaywall) {
|
.sheet(isPresented: $showPaywall) {
|
||||||
PaywallView()
|
PaywallView(source: "pro_gate_\(feature.rawValue)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ struct ProGateButtonModifier: ViewModifier {
|
|||||||
content
|
content
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showPaywall) {
|
.sheet(isPresented: $showPaywall) {
|
||||||
PaywallView()
|
PaywallView(source: "pro_gate_\(feature.rawValue)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -477,6 +477,9 @@ struct OnboardingPaywallView: View {
|
|||||||
.padding(.bottom, Theme.Spacing.xl)
|
.padding(.bottom, Theme.Spacing.xl)
|
||||||
}
|
}
|
||||||
.background(Theme.backgroundGradient(colorScheme))
|
.background(Theme.backgroundGradient(colorScheme))
|
||||||
|
.onAppear {
|
||||||
|
AnalyticsManager.shared.track(.onboardingPaywallViewed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Feature Page
|
// MARK: - Feature Page
|
||||||
@@ -556,7 +559,7 @@ struct OnboardingPaywallView: View {
|
|||||||
// MARK: - Pricing Page
|
// MARK: - Pricing Page
|
||||||
|
|
||||||
private var pricingPage: some View {
|
private var pricingPage: some View {
|
||||||
PaywallView()
|
PaywallView(source: "onboarding")
|
||||||
.storeButton(.hidden, for: .cancellation)
|
.storeButton(.hidden, for: .cancellation)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,6 +587,7 @@ struct OnboardingPaywallView: View {
|
|||||||
// Continue free (always visible)
|
// Continue free (always visible)
|
||||||
Button {
|
Button {
|
||||||
markOnboardingSeen()
|
markOnboardingSeen()
|
||||||
|
AnalyticsManager.shared.track(.onboardingPaywallDismissed)
|
||||||
isPresented = false
|
isPresented = false
|
||||||
} label: {
|
} label: {
|
||||||
Text("Continue with Free")
|
Text("Continue with Free")
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ struct PaywallView: View {
|
|||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
private let storeManager = StoreManager.shared
|
private let storeManager = StoreManager.shared
|
||||||
|
let source: String
|
||||||
|
|
||||||
|
init(source: String = "unknown") {
|
||||||
|
self.source = source
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SubscriptionStoreView(subscriptions: storeManager.products.filter { $0.subscription != nil }) {
|
SubscriptionStoreView(subscriptions: storeManager.products.filter { $0.subscription != nil }) {
|
||||||
@@ -41,14 +46,34 @@ struct PaywallView: View {
|
|||||||
.storeButton(.visible, for: .restorePurchases)
|
.storeButton(.visible, for: .restorePurchases)
|
||||||
.subscriptionStoreControlStyle(.prominentPicker)
|
.subscriptionStoreControlStyle(.prominentPicker)
|
||||||
.subscriptionStoreButtonLabel(.displayName.multiline)
|
.subscriptionStoreButtonLabel(.displayName.multiline)
|
||||||
.onInAppPurchaseCompletion { _, result in
|
.onInAppPurchaseStart { product in
|
||||||
if case .success(.success) = result {
|
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 storeManager.updateEntitlements()
|
||||||
|
storeManager.trackSubscriptionAnalytics(source: "purchase_success")
|
||||||
|
}
|
||||||
dismiss()
|
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
await storeManager.loadProducts()
|
await storeManager.loadProducts()
|
||||||
}
|
}
|
||||||
|
.onAppear {
|
||||||
|
AnalyticsManager.shared.trackPaywallViewed(source: source)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func featurePill(icon: String, text: String) -> some View {
|
private func featurePill(icon: String, text: String) -> some View {
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ final class PollCreationViewModel {
|
|||||||
)
|
)
|
||||||
|
|
||||||
createdPoll = try await pollService.createPoll(poll)
|
createdPoll = try await pollService.createPoll(poll)
|
||||||
|
AnalyticsManager.shared.track(.pollCreated(optionCount: selectedTrips.count))
|
||||||
} catch let pollError as PollError {
|
} catch let pollError as PollError {
|
||||||
error = pollError
|
error = pollError
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ final class PollVotingViewModel {
|
|||||||
|
|
||||||
_ = try await pollService.submitVote(vote)
|
_ = try await pollService.submitVote(vote)
|
||||||
didSubmit = true
|
didSubmit = true
|
||||||
|
AnalyticsManager.shared.track(.pollVoted(pollId: pollId.uuidString))
|
||||||
} catch let pollError as PollError {
|
} catch let pollError as PollError {
|
||||||
error = pollError
|
error = pollError
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ final class ProgressViewModel {
|
|||||||
|
|
||||||
func selectSport(_ sport: Sport) {
|
func selectSport(_ sport: Sport) {
|
||||||
selectedSport = sport
|
selectedSport = sport
|
||||||
|
AnalyticsManager.shared.track(.sportSwitched(sport: sport.rawValue))
|
||||||
}
|
}
|
||||||
|
|
||||||
func clearError() {
|
func clearError() {
|
||||||
@@ -188,6 +189,10 @@ final class ProgressViewModel {
|
|||||||
func deleteVisit(_ visit: StadiumVisit) async throws {
|
func deleteVisit(_ visit: StadiumVisit) async throws {
|
||||||
guard let container = modelContainer else { return }
|
guard let container = modelContainer else { return }
|
||||||
|
|
||||||
|
if let sport = visit.sportEnum {
|
||||||
|
AnalyticsManager.shared.track(.stadiumVisitDeleted(stadiumId: visit.stadiumId, sport: sport.rawValue))
|
||||||
|
}
|
||||||
|
|
||||||
let context = ModelContext(container)
|
let context = ModelContext(container)
|
||||||
context.delete(visit)
|
context.delete(visit)
|
||||||
try context.save()
|
try context.save()
|
||||||
|
|||||||
@@ -408,6 +408,7 @@ struct StadiumVisitSheet: View {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
try modelContext.save()
|
try modelContext.save()
|
||||||
|
AnalyticsManager.shared.track(.stadiumVisitAdded(stadiumId: stadium.id, sport: selectedSport.rawValue))
|
||||||
onSave?(visit)
|
onSave?(visit)
|
||||||
dismiss()
|
dismiss()
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ final class ScheduleViewModel {
|
|||||||
|
|
||||||
self.diagnostics = newDiagnostics
|
self.diagnostics = newDiagnostics
|
||||||
|
|
||||||
|
AnalyticsManager.shared.track(.scheduleViewed(sports: Array(selectedSports).map(\.rawValue)))
|
||||||
logger.info("📅 Returned \(self.games.count) games")
|
logger.info("📅 Returned \(self.games.count) games")
|
||||||
for (sport, count) in sportCounts.sorted(by: { $0.key.rawValue < $1.key.rawValue }) {
|
for (sport, count) in sportCounts.sorted(by: { $0.key.rawValue < $1.key.rawValue }) {
|
||||||
logger.info("📅 \(sport.rawValue): \(count) games")
|
logger.info("📅 \(sport.rawValue): \(count) games")
|
||||||
@@ -149,6 +150,7 @@ final class ScheduleViewModel {
|
|||||||
} else {
|
} else {
|
||||||
selectedSports.insert(sport)
|
selectedSports.insert(sport)
|
||||||
}
|
}
|
||||||
|
AnalyticsManager.shared.track(.scheduleFiltered(sport: sport.rawValue, dateRange: "\(startDate.formatted(.dateTime.month().day())) - \(endDate.formatted(.dateTime.month().day()))"))
|
||||||
Task {
|
Task {
|
||||||
await loadGames()
|
await loadGames()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ final class SettingsViewModel {
|
|||||||
|
|
||||||
var selectedTheme: AppTheme {
|
var selectedTheme: AppTheme {
|
||||||
didSet {
|
didSet {
|
||||||
|
let oldName = oldValue.displayName
|
||||||
ThemeManager.shared.currentTheme = selectedTheme
|
ThemeManager.shared.currentTheme = selectedTheme
|
||||||
|
AnalyticsManager.shared.track(.themeChanged(from: oldName, to: selectedTheme.displayName))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,7 +25,10 @@ final class SettingsViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var maxDrivingHoursPerDay: Int {
|
var maxDrivingHoursPerDay: Int {
|
||||||
didSet { savePreferences() }
|
didSet {
|
||||||
|
savePreferences()
|
||||||
|
AnalyticsManager.shared.track(.drivingHoursChanged(hours: maxDrivingHoursPerDay))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - App Info
|
// MARK: - App Info
|
||||||
@@ -63,8 +68,10 @@ final class SettingsViewModel {
|
|||||||
// Don't allow removing all sports
|
// Don't allow removing all sports
|
||||||
guard selectedSports.count > 1 else { return }
|
guard selectedSports.count > 1 else { return }
|
||||||
selectedSports.remove(sport)
|
selectedSports.remove(sport)
|
||||||
|
AnalyticsManager.shared.track(.sportToggled(sport: sport.rawValue, enabled: false))
|
||||||
} else {
|
} else {
|
||||||
selectedSports.insert(sport)
|
selectedSports.insert(sport)
|
||||||
|
AnalyticsManager.shared.track(.sportToggled(sport: sport.rawValue, enabled: true))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,6 +80,7 @@ final class SettingsViewModel {
|
|||||||
selectedSports = Set(Sport.supported)
|
selectedSports = Set(Sport.supported)
|
||||||
maxDrivingHoursPerDay = 8
|
maxDrivingHoursPerDay = 8
|
||||||
AppearanceManager.shared.currentMode = .system
|
AppearanceManager.shared.currentMode = .system
|
||||||
|
AnalyticsManager.shared.track(.settingsReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Persistence
|
// MARK: - Persistence
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ struct SettingsView: View {
|
|||||||
// Travel Preferences
|
// Travel Preferences
|
||||||
travelSection
|
travelSection
|
||||||
|
|
||||||
|
// Privacy
|
||||||
|
privacySection
|
||||||
|
|
||||||
// Icon Generator
|
// Icon Generator
|
||||||
iconGeneratorSection
|
iconGeneratorSection
|
||||||
|
|
||||||
@@ -79,6 +82,7 @@ struct SettingsView: View {
|
|||||||
Button {
|
Button {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
AppearanceManager.shared.currentMode = mode
|
AppearanceManager.shared.currentMode = mode
|
||||||
|
AnalyticsManager.shared.track(.appearanceChanged(mode: mode.displayName))
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 12) {
|
||||||
@@ -181,7 +185,10 @@ struct SettingsView: View {
|
|||||||
Section {
|
Section {
|
||||||
Toggle(isOn: Binding(
|
Toggle(isOn: Binding(
|
||||||
get: { DesignStyleManager.shared.animationsEnabled },
|
get: { DesignStyleManager.shared.animationsEnabled },
|
||||||
set: { DesignStyleManager.shared.animationsEnabled = $0 }
|
set: {
|
||||||
|
DesignStyleManager.shared.animationsEnabled = $0
|
||||||
|
AnalyticsManager.shared.track(.animationsToggled(enabled: $0))
|
||||||
|
}
|
||||||
)) {
|
)) {
|
||||||
Label {
|
Label {
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
@@ -257,6 +264,42 @@ struct SettingsView: View {
|
|||||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Privacy Section
|
||||||
|
|
||||||
|
private var privacySection: some View {
|
||||||
|
Section {
|
||||||
|
Toggle(isOn: Binding(
|
||||||
|
get: { !AnalyticsManager.shared.isOptedOut },
|
||||||
|
set: { enabled in
|
||||||
|
if enabled {
|
||||||
|
AnalyticsManager.shared.optIn()
|
||||||
|
} else {
|
||||||
|
AnalyticsManager.shared.optOut()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)) {
|
||||||
|
Label {
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text("Share Analytics")
|
||||||
|
.font(.body)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
Text("Help improve SportsTime by sharing anonymous usage data")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
} icon: {
|
||||||
|
Image(systemName: "chart.bar.xaxis")
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} header: {
|
||||||
|
Text("Privacy")
|
||||||
|
} footer: {
|
||||||
|
Text("No personal data is collected. Analytics are fully anonymous.")
|
||||||
|
}
|
||||||
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - About Section
|
// MARK: - About Section
|
||||||
|
|
||||||
private var aboutSection: some View {
|
private var aboutSection: some View {
|
||||||
@@ -573,7 +616,7 @@ struct SettingsView: View {
|
|||||||
|
|
||||||
Button {
|
Button {
|
||||||
Task {
|
Task {
|
||||||
await StoreManager.shared.restorePurchases()
|
await StoreManager.shared.restorePurchases(source: "settings")
|
||||||
}
|
}
|
||||||
} label: {
|
} label: {
|
||||||
Label("Restore Purchases", systemImage: "arrow.clockwise")
|
Label("Restore Purchases", systemImage: "arrow.clockwise")
|
||||||
@@ -584,7 +627,7 @@ struct SettingsView: View {
|
|||||||
}
|
}
|
||||||
.listRowBackground(Theme.cardBackground(colorScheme))
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
||||||
.sheet(isPresented: $showPaywall) {
|
.sheet(isPresented: $showPaywall) {
|
||||||
PaywallView()
|
PaywallView(source: "settings")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ struct TripDetailView: View {
|
|||||||
Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?")
|
Text("This trip has \(multiRouteChunks.flatMap { $0 }.count) stops, which exceeds Apple Maps' limit of 16. Open the route in parts?")
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
|
AnalyticsManager.shared.track(.tripViewed(tripId: trip.id.uuidString, source: allowCustomItems ? "saved" : "new"))
|
||||||
checkIfSaved()
|
checkIfSaved()
|
||||||
// Demo mode: auto-favorite the trip
|
// Demo mode: auto-favorite the trip
|
||||||
if isDemoMode && !hasAppliedDemoSelection && !isSaved {
|
if isDemoMode && !hasAppliedDemoSelection && !isSaved {
|
||||||
@@ -1203,6 +1204,7 @@ struct TripDetailView: View {
|
|||||||
private func exportPDF() async {
|
private func exportPDF() async {
|
||||||
isExporting = true
|
isExporting = true
|
||||||
exportProgress = nil
|
exportProgress = nil
|
||||||
|
AnalyticsManager.shared.track(.pdfExportStarted(tripId: trip.id.uuidString, stopCount: trip.stops.count))
|
||||||
|
|
||||||
do {
|
do {
|
||||||
// Build complete itinerary items (games + travel + custom)
|
// Build complete itinerary items (games + travel + custom)
|
||||||
@@ -1219,8 +1221,9 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
exportURL = url
|
exportURL = url
|
||||||
showExportSheet = true
|
showExportSheet = true
|
||||||
|
AnalyticsManager.shared.track(.pdfExportCompleted(tripId: trip.id.uuidString))
|
||||||
} catch {
|
} catch {
|
||||||
// PDF export failed silently
|
AnalyticsManager.shared.track(.pdfExportFailed(tripId: trip.id.uuidString, error: error.localizedDescription))
|
||||||
}
|
}
|
||||||
|
|
||||||
isExporting = false
|
isExporting = false
|
||||||
@@ -1323,6 +1326,11 @@ struct TripDetailView: View {
|
|||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
||||||
isSaved = true
|
isSaved = true
|
||||||
}
|
}
|
||||||
|
AnalyticsManager.shared.track(.tripSaved(
|
||||||
|
tripId: trip.id.uuidString,
|
||||||
|
stopCount: trip.stops.count,
|
||||||
|
gameCount: trip.totalGames
|
||||||
|
))
|
||||||
} catch {
|
} catch {
|
||||||
// Save failed silently
|
// Save failed silently
|
||||||
}
|
}
|
||||||
@@ -1343,6 +1351,7 @@ struct TripDetailView: View {
|
|||||||
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
withAnimation(.spring(response: 0.3, dampingFraction: 0.6)) {
|
||||||
isSaved = false
|
isSaved = false
|
||||||
}
|
}
|
||||||
|
AnalyticsManager.shared.track(.tripDeleted(tripId: tripId.uuidString))
|
||||||
} catch {
|
} catch {
|
||||||
// Unsave failed silently
|
// Unsave failed silently
|
||||||
}
|
}
|
||||||
@@ -2047,7 +2056,7 @@ private struct SheetModifiers: ViewModifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showProPaywall) {
|
.sheet(isPresented: $showProPaywall) {
|
||||||
PaywallView()
|
PaywallView(source: "trip_detail")
|
||||||
}
|
}
|
||||||
.sheet(item: $addItemAnchor) { anchor in
|
.sheet(item: $addItemAnchor) { anchor in
|
||||||
QuickAddItemSheet(
|
QuickAddItemSheet(
|
||||||
|
|||||||
@@ -165,6 +165,8 @@ struct TripWizardView: View {
|
|||||||
// MARK: - Planning
|
// MARK: - Planning
|
||||||
|
|
||||||
private func planTrip() async {
|
private func planTrip() async {
|
||||||
|
let mode = viewModel.planningMode?.rawValue ?? "unknown"
|
||||||
|
AnalyticsManager.shared.track(.tripWizardStarted(mode: mode))
|
||||||
viewModel.isPlanning = true
|
viewModel.isPlanning = true
|
||||||
defer { viewModel.isPlanning = false }
|
defer { viewModel.isPlanning = false }
|
||||||
|
|
||||||
@@ -242,18 +244,29 @@ struct TripWizardView: View {
|
|||||||
if options.isEmpty {
|
if options.isEmpty {
|
||||||
planningError = "No valid trip options found for your criteria. Try expanding your date range or regions."
|
planningError = "No valid trip options found for your criteria. Try expanding your date range or regions."
|
||||||
showError = true
|
showError = true
|
||||||
|
AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: "no_options_found"))
|
||||||
} else {
|
} else {
|
||||||
tripOptions = options
|
tripOptions = options
|
||||||
gamesForDisplay = richGamesDict
|
gamesForDisplay = richGamesDict
|
||||||
showTripOptions = true
|
showTripOptions = true
|
||||||
|
if let first = options.first {
|
||||||
|
AnalyticsManager.shared.track(.tripPlanned(
|
||||||
|
sportCount: viewModel.selectedSports.count,
|
||||||
|
stopCount: first.stops.count,
|
||||||
|
dayCount: first.stops.count,
|
||||||
|
mode: mode
|
||||||
|
))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case .failure(let failure):
|
case .failure(let failure):
|
||||||
planningError = failure.message
|
planningError = failure.message
|
||||||
showError = true
|
showError = true
|
||||||
|
AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: failure.message))
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
planningError = error.localizedDescription
|
planningError = error.localizedDescription
|
||||||
showError = true
|
showError = true
|
||||||
|
AnalyticsManager.shared.track(.tripPlanFailed(mode: mode, error: error.localizedDescription))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -134,6 +134,10 @@ struct BootstrappedContentView: View {
|
|||||||
.onChange(of: scenePhase) { _, newPhase in
|
.onChange(of: scenePhase) { _, newPhase in
|
||||||
switch newPhase {
|
switch newPhase {
|
||||||
case .active:
|
case .active:
|
||||||
|
// Refresh super properties (subscription status may have changed)
|
||||||
|
AnalyticsManager.shared.updateSuperProperties()
|
||||||
|
// Track subscription state with rich properties for funnel analysis
|
||||||
|
StoreManager.shared.trackSubscriptionAnalytics(source: "app_foreground")
|
||||||
// Sync when app comes to foreground (but not on initial launch)
|
// Sync when app comes to foreground (but not on initial launch)
|
||||||
if hasCompletedInitialSync {
|
if hasCompletedInitialSync {
|
||||||
Task {
|
Task {
|
||||||
@@ -141,6 +145,8 @@ struct BootstrappedContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .background:
|
case .background:
|
||||||
|
// Flush pending analytics events
|
||||||
|
AnalyticsManager.shared.flush()
|
||||||
// Schedule background tasks when app goes to background
|
// Schedule background tasks when app goes to background
|
||||||
BackgroundSyncManager.shared.scheduleAllTasks()
|
BackgroundSyncManager.shared.scheduleAllTasks()
|
||||||
default:
|
default:
|
||||||
@@ -190,15 +196,19 @@ struct BootstrappedContentView: View {
|
|||||||
}
|
}
|
||||||
NetworkMonitor.shared.startMonitoring()
|
NetworkMonitor.shared.startMonitoring()
|
||||||
|
|
||||||
// 7. App is now usable
|
// 7. Configure analytics
|
||||||
print("🚀 [BOOT] Step 7: Bootstrap complete - app ready")
|
print("🚀 [BOOT] Step 7: Configuring analytics...")
|
||||||
|
AnalyticsManager.shared.configure()
|
||||||
|
|
||||||
|
// 8. App is now usable
|
||||||
|
print("🚀 [BOOT] Step 8: Bootstrap complete - app ready")
|
||||||
isBootstrapping = false
|
isBootstrapping = false
|
||||||
|
|
||||||
// 8. Schedule background tasks for future syncs
|
// 9. Schedule background tasks for future syncs
|
||||||
BackgroundSyncManager.shared.scheduleAllTasks()
|
BackgroundSyncManager.shared.scheduleAllTasks()
|
||||||
|
|
||||||
// 9. Background: Try to refresh from CloudKit (non-blocking)
|
// 10. Background: Try to refresh from CloudKit (non-blocking)
|
||||||
print("🚀 [BOOT] Step 9: Starting background CloudKit sync...")
|
print("🚀 [BOOT] Step 10: Starting background CloudKit sync...")
|
||||||
Task.detached(priority: .background) {
|
Task.detached(priority: .background) {
|
||||||
await self.performBackgroundSync(context: context)
|
await self.performBackgroundSync(context: context)
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
|
|||||||
Reference in New Issue
Block a user