feat(store): add In-App Purchase system with Pro subscription

Implement freemium model with StoreKit 2:
- StoreManager singleton for purchase/restore/entitlements
- ProFeature enum defining gated features
- PaywallView and OnboardingPaywallView for upsell UI
- ProGate view modifier and ProBadge component

Feature gating:
- Trip saving: 1 free trip, then requires Pro
- PDF export: Pro only with badge indicator
- Progress tab: Shows ProLockedView for free users
- Settings: Subscription management section

Also fixes pre-existing test issues with StadiumVisit
and ItineraryOption model signature changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-13 11:41:40 -06:00
parent e4204175ea
commit 22772fa57f
19 changed files with 1293 additions and 34 deletions

View File

@@ -0,0 +1,35 @@
//
// ProFeatureTests.swift
// SportsTimeTests
//
import Testing
@testable import SportsTime
struct ProFeatureTests {
@Test func allCases_containsExpectedFeatures() {
let features = ProFeature.allCases
#expect(features.contains(.unlimitedTrips))
#expect(features.contains(.pdfExport))
#expect(features.contains(.progressTracking))
#expect(features.count == 3)
}
@Test func displayName_returnsHumanReadableString() {
#expect(ProFeature.unlimitedTrips.displayName == "Unlimited Trips")
#expect(ProFeature.pdfExport.displayName == "PDF Export")
#expect(ProFeature.progressTracking.displayName == "Progress Tracking")
}
@Test func description_returnsMarketingCopy() {
#expect(ProFeature.unlimitedTrips.description.contains("trips"))
#expect(ProFeature.pdfExport.description.contains("PDF"))
#expect(ProFeature.progressTracking.description.contains("stadium"))
}
@Test func icon_returnsValidSFSymbol() {
#expect(!ProFeature.unlimitedTrips.icon.isEmpty)
#expect(!ProFeature.pdfExport.icon.isEmpty)
#expect(!ProFeature.progressTracking.icon.isEmpty)
}
}

View File

@@ -0,0 +1,16 @@
//
// ProGateTests.swift
// SportsTimeTests
//
import Testing
import SwiftUI
@testable import SportsTime
struct ProGateTests {
@Test func proGate_createsViewModifier() {
// Just verify the modifier compiles and can be applied
let _ = Text("Test").proGate(feature: .pdfExport)
#expect(true) // If we got here, it compiles
}
}

View File

@@ -0,0 +1,16 @@
//
// StoreErrorTests.swift
// SportsTimeTests
//
import Testing
import Foundation
@testable import SportsTime
struct StoreErrorTests {
@Test func errorDescription_returnsUserFriendlyMessage() {
#expect(StoreError.productNotFound.localizedDescription.contains("not found"))
#expect(StoreError.purchaseFailed.localizedDescription.contains("failed"))
#expect(StoreError.verificationFailed.localizedDescription.contains("verify"))
}
}

View File

@@ -0,0 +1,36 @@
//
// StoreManagerTests.swift
// SportsTimeTests
//
import Testing
import StoreKit
@testable import SportsTime
struct StoreManagerTests {
@Test func shared_returnsSingletonInstance() async {
let instance1 = await StoreManager.shared
let instance2 = await StoreManager.shared
#expect(instance1 === instance2)
}
@Test func isPro_isAccessible() async {
let manager = await StoreManager.shared
// Fresh state should not be Pro
// Note: In real tests, we'd reset state first
let _ = await manager.isPro // Just verify it's accessible
#expect(true)
}
@MainActor
@Test func proProductIDs_containsExpectedProducts() {
#expect(StoreManager.proProductIDs.contains("com.sportstime.pro.monthly"))
#expect(StoreManager.proProductIDs.contains("com.sportstime.pro.annual"))
#expect(StoreManager.proProductIDs.count == 2)
}
@MainActor
@Test func freeTripLimit_returnsOne() {
#expect(StoreManager.freeTripLimit == 1)
}
}