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

@@ -17,6 +17,7 @@ struct HomeView: View {
@State private var suggestedTripsGenerator = SuggestedTripsGenerator()
@State private var selectedSuggestedTrip: SuggestedTrip?
@State private var displayedTips: [PlanningTip] = []
@State private var showProPaywall = false
var body: some View {
TabView(selection: $selectedTab) {
@@ -86,7 +87,13 @@ struct HomeView: View {
// Progress Tab
NavigationStack {
ProgressTabView()
if StoreManager.shared.isPro {
ProgressTabView()
} else {
ProLockedView(feature: .progressTracking) {
showProPaywall = true
}
}
}
.tabItem {
Label("Progress", systemImage: "chart.bar.fill")
@@ -121,6 +128,9 @@ struct HomeView: View {
TripDetailView(trip: suggestedTrip.trip, games: suggestedTrip.richGames)
}
}
.sheet(isPresented: $showProPaywall) {
PaywallView()
}
}
// MARK: - Hero Card
@@ -548,6 +558,63 @@ struct SavedTripListRow: View {
}
}
// MARK: - Pro Locked View
struct ProLockedView: View {
let feature: ProFeature
let onUnlock: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: Theme.Spacing.xl) {
Spacer()
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 100, height: 100)
Image(systemName: "lock.fill")
.font(.system(size: 40))
.foregroundStyle(Theme.warmOrange)
}
VStack(spacing: Theme.Spacing.sm) {
Text(feature.displayName)
.font(.title2.bold())
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(feature.description)
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
.padding(.horizontal, Theme.Spacing.xl)
}
Button {
onUnlock()
} label: {
HStack {
Image(systemName: "star.fill")
Text("Upgrade to Pro")
}
.fontWeight(.semibold)
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.padding(.horizontal, Theme.Spacing.xl)
Spacer()
Spacer()
}
.themedBackground()
}
}
#Preview {
HomeView()
.modelContainer(for: SavedTrip.self, inMemory: true)