diff --git a/SportsTime/Core/Store/ProFeature.swift b/SportsTime/Core/Store/ProFeature.swift new file mode 100644 index 0000000..10cdad7 --- /dev/null +++ b/SportsTime/Core/Store/ProFeature.swift @@ -0,0 +1,43 @@ +// +// ProFeature.swift +// SportsTime +// +// Defines features gated behind Pro subscription. +// + +import Foundation + +enum ProFeature: String, CaseIterable, Identifiable { + case unlimitedTrips + case pdfExport + case progressTracking + + var id: String { rawValue } + + var displayName: String { + switch self { + case .unlimitedTrips: return "Unlimited Trips" + case .pdfExport: return "PDF Export" + case .progressTracking: return "Progress Tracking" + } + } + + var description: String { + switch self { + case .unlimitedTrips: + return "Save unlimited trips and never lose your itineraries." + case .pdfExport: + return "Export beautiful PDF itineraries to share with friends." + case .progressTracking: + return "Track stadium visits, earn badges, complete your bucket list." + } + } + + var icon: String { + switch self { + case .unlimitedTrips: return "suitcase.fill" + case .pdfExport: return "doc.fill" + case .progressTracking: return "trophy.fill" + } + } +} diff --git a/SportsTime/Core/Store/StoreError.swift b/SportsTime/Core/Store/StoreError.swift new file mode 100644 index 0000000..ebbfb6d --- /dev/null +++ b/SportsTime/Core/Store/StoreError.swift @@ -0,0 +1,26 @@ +// +// StoreError.swift +// SportsTime +// + +import Foundation + +enum StoreError: LocalizedError { + case productNotFound + case purchaseFailed + case verificationFailed + case userCancelled + + var errorDescription: String? { + switch self { + case .productNotFound: + return "Product not found. Please try again later." + case .purchaseFailed: + return "Purchase failed. Please try again." + case .verificationFailed: + return "Could not verify purchase. Please contact support." + case .userCancelled: + return nil // User cancelled, no error message needed + } + } +} diff --git a/SportsTime/Core/Store/StoreManager.swift b/SportsTime/Core/Store/StoreManager.swift new file mode 100644 index 0000000..7c287d7 --- /dev/null +++ b/SportsTime/Core/Store/StoreManager.swift @@ -0,0 +1,142 @@ +// +// StoreManager.swift +// SportsTime +// +// Manages StoreKit 2 subscriptions and entitlements. +// + +import Foundation +import StoreKit + +@Observable +@MainActor +final class StoreManager { + // MARK: - Singleton + + static let shared = StoreManager() + + // MARK: - Constants + + static let proProductIDs: Set = [ + "com.sportstime.pro.monthly", + "com.sportstime.pro.annual" + ] + + static let freeTripLimit = 1 + + // MARK: - Published State + + private(set) var products: [Product] = [] + private(set) var purchasedProductIDs: Set = [] + private(set) var isLoading = false + private(set) var error: StoreError? + + // MARK: - Computed Properties + + var isPro: Bool { + !purchasedProductIDs.intersection(Self.proProductIDs).isEmpty + } + + var monthlyProduct: Product? { + products.first { $0.id == "com.sportstime.pro.monthly" } + } + + var annualProduct: Product? { + products.first { $0.id == "com.sportstime.pro.annual" } + } + + // MARK: - Initialization + + private init() {} + + // MARK: - Product Loading + + func loadProducts() async { + isLoading = true + error = nil + + do { + products = try await Product.products(for: Self.proProductIDs) + isLoading = false + } catch { + self.error = .productNotFound + isLoading = false + } + } + + // MARK: - Entitlement Management + + func updateEntitlements() async { + var purchased: Set = [] + + for await result in Transaction.currentEntitlements { + if case .verified(let transaction) = result { + purchased.insert(transaction.productID) + } + } + + purchasedProductIDs = purchased + } + + // MARK: - Purchase + + func purchase(_ product: Product) async throws { + let result = try await product.purchase() + + switch result { + case .success(let verification): + let transaction = try checkVerified(verification) + await transaction.finish() + await updateEntitlements() + + case .userCancelled: + throw StoreError.userCancelled + + case .pending: + // Ask to Buy or SCA - transaction will appear in updates when approved + break + + @unknown default: + throw StoreError.purchaseFailed + } + } + + // MARK: - Restore + + func restorePurchases() async { + do { + try await AppStore.sync() + } catch { + // Sync failed, but we can still check current entitlements + } + await updateEntitlements() + } + + // MARK: - Transaction Listener + + func listenForTransactions() -> Task { + Task.detached { + for await result in Transaction.updates { + if case .verified(let transaction) = result { + await transaction.finish() + await MainActor.run { + Task { + await StoreManager.shared.updateEntitlements() + } + } + } + } + } + } + + // MARK: - Helpers + + private func checkVerified(_ result: VerificationResult) throws -> T { + switch result { + case .unverified: + throw StoreError.verificationFailed + case .verified(let safe): + return safe + } + } +} diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index ffd2696..ad61b3e 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -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) diff --git a/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift b/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift new file mode 100644 index 0000000..8570d58 --- /dev/null +++ b/SportsTime/Features/Paywall/ViewModifiers/ProGate.swift @@ -0,0 +1,71 @@ +// +// ProGate.swift +// SportsTime +// +// View modifier that gates Pro-only features. +// + +import SwiftUI + +struct ProGateModifier: ViewModifier { + let feature: ProFeature + + @State private var showPaywall = false + + func body(content: Content) -> some View { + content + .onTapGesture { + if !StoreManager.shared.isPro { + showPaywall = true + } + } + .allowsHitTesting(!StoreManager.shared.isPro ? true : true) + .overlay { + if !StoreManager.shared.isPro { + Color.clear + .contentShape(Rectangle()) + .onTapGesture { + showPaywall = true + } + } + } + .sheet(isPresented: $showPaywall) { + PaywallView() + } + } +} + +/// Modifier for buttons that should show paywall when tapped by free users +struct ProGateButtonModifier: ViewModifier { + let feature: ProFeature + let action: () -> Void + + @State private var showPaywall = false + + func body(content: Content) -> some View { + Button { + if StoreManager.shared.isPro { + action() + } else { + showPaywall = true + } + } label: { + content + } + .sheet(isPresented: $showPaywall) { + PaywallView() + } + } +} + +extension View { + /// Gates entire view - tapping shows paywall if not Pro + func proGate(feature: ProFeature) -> some View { + modifier(ProGateModifier(feature: feature)) + } + + /// Gates a button action - shows paywall instead of performing action if not Pro + func proGateButton(feature: ProFeature, action: @escaping () -> Void) -> some View { + modifier(ProGateButtonModifier(feature: feature, action: action)) + } +} diff --git a/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift new file mode 100644 index 0000000..9c9ac36 --- /dev/null +++ b/SportsTime/Features/Paywall/Views/OnboardingPaywallView.swift @@ -0,0 +1,306 @@ +// +// OnboardingPaywallView.swift +// SportsTime +// +// First-launch upsell with feature pages. +// + +import SwiftUI +import StoreKit + +struct OnboardingPaywallView: View { + @Environment(\.colorScheme) private var colorScheme + @Binding var isPresented: Bool + + @State private var currentPage = 0 + @State private var selectedProduct: Product? + @State private var isPurchasing = false + @State private var errorMessage: String? + + private let storeManager = StoreManager.shared + + private let pages: [(icon: String, title: String, description: String, color: Color)] = [ + ("suitcase.fill", "Unlimited Trips", "Plan as many road trips as you want. Never lose your itineraries.", Theme.warmOrange), + ("doc.fill", "Export & Share", "Generate beautiful PDF itineraries to share with friends.", Theme.routeGold), + ("trophy.fill", "Track Your Journey", "Log stadium visits, earn badges, complete your bucket list.", .green) + ] + + var body: some View { + VStack(spacing: 0) { + // Page content + TabView(selection: $currentPage) { + ForEach(0.. some View { + let page = pages[index] + + return VStack(spacing: Theme.Spacing.xl) { + Spacer() + + ZStack { + Circle() + .fill(page.color.opacity(0.15)) + .frame(width: 120, height: 120) + + Image(systemName: page.icon) + .font(.system(size: 50)) + .foregroundStyle(page.color) + } + + VStack(spacing: Theme.Spacing.md) { + Text(page.title) + .font(.title.bold()) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(page.description) + .font(.body) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .multilineTextAlignment(.center) + .padding(.horizontal, Theme.Spacing.xl) + } + + Spacer() + Spacer() + } + } + + // MARK: - Pricing Page + + private var pricingPage: some View { + VStack(spacing: Theme.Spacing.lg) { + Spacer() + + Text("Choose Your Plan") + .font(.title.bold()) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + if storeManager.isLoading { + ProgressView() + } else { + VStack(spacing: Theme.Spacing.md) { + // Annual (recommended) + if let annual = storeManager.annualProduct { + OnboardingPricingRow( + product: annual, + title: "Annual", + subtitle: "Best Value - Save 17%", + isSelected: selectedProduct?.id == annual.id, + isRecommended: true + ) { + selectedProduct = annual + } + } + + // Monthly + if let monthly = storeManager.monthlyProduct { + OnboardingPricingRow( + product: monthly, + title: "Monthly", + subtitle: "Flexible billing", + isSelected: selectedProduct?.id == monthly.id, + isRecommended: false + ) { + selectedProduct = monthly + } + } + } + .padding(.horizontal, Theme.Spacing.lg) + } + + if let error = errorMessage { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + Spacer() + Spacer() + } + } + + // MARK: - Bottom Buttons + + private var bottomButtons: some View { + VStack(spacing: Theme.Spacing.md) { + if currentPage < pages.count { + // Next button + Button { + withAnimation { + currentPage += 1 + } + } label: { + Text("Next") + .fontWeight(.semibold) + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.md) + .background(Theme.warmOrange) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + + // Skip + Button { + markOnboardingSeen() + isPresented = false + } label: { + Text("Continue with Free") + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } else { + // Subscribe button + Button { + Task { + await purchase() + } + } label: { + HStack { + if isPurchasing { + ProgressView() + .tint(.white) + } else { + Text("Subscribe") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.md) + .background(Theme.warmOrange) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + .disabled(selectedProduct == nil || isPurchasing) + + // Continue free + Button { + markOnboardingSeen() + isPresented = false + } label: { + Text("Continue with Free") + .font(.subheadline) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + } + } + + // MARK: - Actions + + private func purchase() async { + guard let product = selectedProduct else { return } + + isPurchasing = true + errorMessage = nil + + do { + try await storeManager.purchase(product) + markOnboardingSeen() + isPresented = false + } catch StoreError.userCancelled { + // User cancelled + } catch { + errorMessage = error.localizedDescription + } + + isPurchasing = false + } + + private func markOnboardingSeen() { + UserDefaults.standard.set(true, forKey: "hasSeenOnboardingPaywall") + } +} + +// MARK: - Pricing Row + +struct OnboardingPricingRow: View { + let product: Product + let title: String + let subtitle: String + let isSelected: Bool + let isRecommended: Bool + let onSelect: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: onSelect) { + HStack { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: Theme.Spacing.xs) { + Text(title) + .font(.body.bold()) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + if isRecommended { + Text("BEST") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Theme.warmOrange, in: Capsule()) + } + } + + Text(subtitle) + .font(.caption) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() + + Text(product.displayPrice) + .font(.title3.bold()) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Theme.warmOrange : Theme.textMuted(colorScheme)) + } + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(isSelected ? Theme.warmOrange : Theme.surfaceGlow(colorScheme), lineWidth: isSelected ? 2 : 1) + } + } + .buttonStyle(.plain) + } +} + +#Preview { + OnboardingPaywallView(isPresented: .constant(true)) +} diff --git a/SportsTime/Features/Paywall/Views/PaywallView.swift b/SportsTime/Features/Paywall/Views/PaywallView.swift new file mode 100644 index 0000000..d671b7e --- /dev/null +++ b/SportsTime/Features/Paywall/Views/PaywallView.swift @@ -0,0 +1,311 @@ +// +// PaywallView.swift +// SportsTime +// +// Full-screen paywall for Pro subscription. +// + +import SwiftUI +import StoreKit + +struct PaywallView: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.dismiss) private var dismiss + + @State private var selectedProduct: Product? + @State private var isPurchasing = false + @State private var errorMessage: String? + + private let storeManager = StoreManager.shared + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: Theme.Spacing.xl) { + // Hero + heroSection + + // Features + featuresSection + + // Pricing + pricingSection + + // Error message + if let error = errorMessage { + Text(error) + .font(.subheadline) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + + // Subscribe button + subscribeButton + + // Restore purchases + restoreButton + + // Legal + legalSection + } + .padding(Theme.Spacing.lg) + } + .themedBackground() + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + } + .task { + await storeManager.loadProducts() + // Default to annual + selectedProduct = storeManager.annualProduct ?? storeManager.monthlyProduct + } + } + } + + // MARK: - Hero Section + + private var heroSection: some View { + VStack(spacing: Theme.Spacing.md) { + Image(systemName: "star.circle.fill") + .font(.system(size: 60)) + .foregroundStyle(Theme.warmOrange) + + Text("Upgrade to Pro") + .font(.largeTitle.bold()) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text("Unlock the full SportsTime experience") + .font(.body) + .foregroundStyle(Theme.textSecondary(colorScheme)) + .multilineTextAlignment(.center) + } + } + + // MARK: - Features Section + + private var featuresSection: some View { + VStack(spacing: Theme.Spacing.md) { + ForEach(ProFeature.allCases) { feature in + HStack(spacing: Theme.Spacing.md) { + ZStack { + Circle() + .fill(Theme.warmOrange.opacity(0.15)) + .frame(width: 44, height: 44) + + Image(systemName: feature.icon) + .foregroundStyle(Theme.warmOrange) + } + + VStack(alignment: .leading, spacing: 4) { + Text(feature.displayName) + .font(.body) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(feature.description) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + + Spacer() + + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + .padding(Theme.Spacing.md) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + } + } + + // MARK: - Pricing Section + + private var pricingSection: some View { + VStack(spacing: Theme.Spacing.md) { + if storeManager.isLoading { + ProgressView() + .padding() + } else { + HStack(spacing: Theme.Spacing.md) { + // Monthly option + if let monthly = storeManager.monthlyProduct { + PricingOptionCard( + product: monthly, + title: "Monthly", + isSelected: selectedProduct?.id == monthly.id, + savingsText: nil + ) { + selectedProduct = monthly + } + } + + // Annual option + if let annual = storeManager.annualProduct { + PricingOptionCard( + product: annual, + title: "Annual", + isSelected: selectedProduct?.id == annual.id, + savingsText: "Save 17%" + ) { + selectedProduct = annual + } + } + } + } + } + } + + // MARK: - Subscribe Button + + private var subscribeButton: some View { + Button { + Task { + await purchase() + } + } label: { + HStack { + if isPurchasing { + ProgressView() + .tint(.white) + } else { + Text("Subscribe Now") + .fontWeight(.semibold) + } + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.md) + .background(Theme.warmOrange) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)) + } + .disabled(selectedProduct == nil || isPurchasing) + .opacity(selectedProduct == nil ? 0.5 : 1) + } + + // MARK: - Restore Button + + private var restoreButton: some View { + Button { + Task { + await storeManager.restorePurchases() + if storeManager.isPro { + dismiss() + } else { + errorMessage = "No active subscription found." + } + } + } label: { + Text("Restore Purchases") + .font(.subheadline) + .foregroundStyle(Theme.warmOrange) + } + } + + // MARK: - Legal Section + + private var legalSection: some View { + VStack(spacing: Theme.Spacing.xs) { + Text("Subscriptions automatically renew unless cancelled at least 24 hours before the end of the current period.") + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + .multilineTextAlignment(.center) + + HStack(spacing: Theme.Spacing.md) { + Link("Terms", destination: URL(string: "https://88oakapps.com/terms")!) + Link("Privacy", destination: URL(string: "https://88oakapps.com/privacy")!) + } + .font(.caption) + .foregroundStyle(Theme.warmOrange) + } + .padding(.top, Theme.Spacing.md) + } + + // MARK: - Actions + + private func purchase() async { + guard let product = selectedProduct else { return } + + isPurchasing = true + errorMessage = nil + + do { + try await storeManager.purchase(product) + dismiss() + } catch StoreError.userCancelled { + // User cancelled, no error message + } catch { + errorMessage = error.localizedDescription + } + + isPurchasing = false + } +} + +// MARK: - Pricing Option Card + +struct PricingOptionCard: View { + let product: Product + let title: String + let isSelected: Bool + let savingsText: String? + let onSelect: () -> Void + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Button(action: onSelect) { + VStack(spacing: Theme.Spacing.sm) { + if let savings = savingsText { + Text(savings) + .font(.caption.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(Theme.warmOrange, in: Capsule()) + } + + Text(title) + .font(.subheadline) + .foregroundStyle(Theme.textSecondary(colorScheme)) + + Text(product.displayPrice) + .font(.title2.bold()) + .foregroundStyle(Theme.textPrimary(colorScheme)) + + Text(pricePerMonth) + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + } + .frame(maxWidth: .infinity) + .padding(Theme.Spacing.lg) + .background(Theme.cardBackground(colorScheme)) + .clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large)) + .overlay { + RoundedRectangle(cornerRadius: Theme.CornerRadius.large) + .stroke(isSelected ? Theme.warmOrange : Theme.surfaceGlow(colorScheme), lineWidth: isSelected ? 2 : 1) + } + } + .buttonStyle(.plain) + } + + private var pricePerMonth: String { + if product.id.contains("annual") { + let monthly = product.price / 12 + return "\(monthly.formatted(.currency(code: product.priceFormatStyle.currencyCode ?? "USD")))/mo" + } + return "per month" + } +} + +#Preview { + PaywallView() +} diff --git a/SportsTime/Features/Paywall/Views/ProBadge.swift b/SportsTime/Features/Paywall/Views/ProBadge.swift new file mode 100644 index 0000000..fee5aaf --- /dev/null +++ b/SportsTime/Features/Paywall/Views/ProBadge.swift @@ -0,0 +1,46 @@ +// +// ProBadge.swift +// SportsTime +// +// Small "PRO" badge indicator for locked features. +// + +import SwiftUI + +struct ProBadge: View { + var body: some View { + Text("PRO") + .font(.caption2.bold()) + .foregroundStyle(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(Theme.warmOrange, in: Capsule()) + } +} + +// MARK: - View Modifier + +extension View { + /// Adds a small PRO badge overlay to indicate locked feature + func proBadge(alignment: Alignment = .topTrailing) -> some View { + overlay(alignment: alignment) { + if !StoreManager.shared.isPro { + ProBadge() + .padding(4) + } + } + } +} + +#Preview { + VStack(spacing: 20) { + ProBadge() + + // Example usage + RoundedRectangle(cornerRadius: 12) + .fill(.blue.opacity(0.2)) + .frame(width: 100, height: 60) + .proBadge() + } + .padding() +} diff --git a/SportsTime/Features/Settings/Views/SettingsView.swift b/SportsTime/Features/Settings/Views/SettingsView.swift index c2360d5..19e75a3 100644 --- a/SportsTime/Features/Settings/Views/SettingsView.swift +++ b/SportsTime/Features/Settings/Views/SettingsView.swift @@ -9,9 +9,13 @@ struct SettingsView: View { @Environment(\.colorScheme) private var colorScheme @State private var viewModel = SettingsViewModel() @State private var showResetConfirmation = false + @State private var showPaywall = false var body: some View { List { + // Subscription + subscriptionSection + // Theme Selection themeSection @@ -187,6 +191,83 @@ struct SettingsView: View { .listRowBackground(Theme.cardBackground(colorScheme)) } + // MARK: - Subscription Section + + private var subscriptionSection: some View { + Section { + if StoreManager.shared.isPro { + // Pro user - show manage option + HStack { + Label { + VStack(alignment: .leading, spacing: 4) { + Text("SportsTime Pro") + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text("Active subscription") + .font(.caption) + .foregroundStyle(.green) + } + } icon: { + Image(systemName: "star.fill") + .foregroundStyle(Theme.warmOrange) + } + + Spacer() + + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + } + + Button { + if let url = URL(string: "https://apps.apple.com/account/subscriptions") { + UIApplication.shared.open(url) + } + } label: { + Label("Manage Subscription", systemImage: "gear") + } + } else { + // Free user - show upgrade option + Button { + showPaywall = true + } label: { + HStack { + Label { + VStack(alignment: .leading, spacing: 4) { + Text("Upgrade to Pro") + .foregroundStyle(Theme.textPrimary(colorScheme)) + Text("Unlimited trips, PDF export, progress tracking") + .font(.caption) + .foregroundStyle(Theme.textSecondary(colorScheme)) + } + } icon: { + Image(systemName: "star.fill") + .foregroundStyle(Theme.warmOrange) + } + + Spacer() + + Image(systemName: "chevron.right") + .foregroundStyle(Theme.textMuted(colorScheme)) + } + } + .buttonStyle(.plain) + + Button { + Task { + await StoreManager.shared.restorePurchases() + } + } label: { + Label("Restore Purchases", systemImage: "arrow.clockwise") + } + } + } header: { + Text("Subscription") + } + .listRowBackground(Theme.cardBackground(colorScheme)) + .sheet(isPresented: $showPaywall) { + PaywallView() + } + } + // MARK: - Helpers private func sportColor(for sport: Sport) -> Color { diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index ddc0152..94fb50c 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -14,6 +14,8 @@ struct TripDetailView: View { let trip: Trip let games: [String: RichGame] + @Query private var savedTrips: [SavedTrip] + @State private var showProPaywall = false @State private var selectedDay: ItineraryDay? @State private var showExportSheet = false @State private var showShareSheet = false @@ -69,12 +71,21 @@ struct TripDetailView: View { } Button { - Task { - await exportPDF() + if StoreManager.shared.isPro { + Task { + await exportPDF() + } + } else { + showProPaywall = true } } label: { - Image(systemName: "doc.fill") - .foregroundStyle(Theme.warmOrange) + HStack(spacing: 2) { + Image(systemName: "doc.fill") + if !StoreManager.shared.isPro { + ProBadge() + } + } + .foregroundStyle(Theme.warmOrange) } } } @@ -90,6 +101,9 @@ struct TripDetailView: View { ShareSheet(items: [trip.name, trip.formattedDateRange]) } } + .sheet(isPresented: $showProPaywall) { + PaywallView() + } .onAppear { checkIfSaved() } @@ -523,6 +537,12 @@ struct TripDetailView: View { } private func saveTrip() { + // Check trip limit for free users + if !StoreManager.shared.isPro && savedTrips.count >= StoreManager.freeTripLimit { + showProPaywall = true + return + } + guard let savedTrip = SavedTrip.from(trip, games: games, status: .planned) else { return } diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 1abb973..7a8c0c6 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -10,6 +10,14 @@ import SwiftData @main struct SportsTimeApp: App { + /// Task that listens for StoreKit transaction updates + private var transactionListener: Task? + + init() { + // Start listening for transactions immediately + transactionListener = StoreManager.shared.listenForTransactions() + } + var sharedModelContainer: ModelContainer = { let schema = Schema([ // User data models @@ -63,6 +71,11 @@ struct BootstrappedContentView: View { @State private var isBootstrapping = true @State private var bootstrapError: Error? @State private var hasCompletedInitialSync = false + @State private var showOnboardingPaywall = false + + private var shouldShowOnboardingPaywall: Bool { + !UserDefaults.standard.bool(forKey: "hasSeenOnboardingPaywall") && !StoreManager.shared.isPro + } var body: some View { Group { @@ -76,6 +89,15 @@ struct BootstrappedContentView: View { } } else { HomeView() + .sheet(isPresented: $showOnboardingPaywall) { + OnboardingPaywallView(isPresented: $showOnboardingPaywall) + .interactiveDismissDisabled() + } + .onAppear { + if shouldShowOnboardingPaywall { + showOnboardingPaywall = true + } + } } } .task { @@ -109,10 +131,14 @@ struct BootstrappedContentView: View { // 3. Load data from SwiftData into memory await AppDataProvider.shared.loadInitialData() - // 4. App is now usable + // 4. Load store products and entitlements + await StoreManager.shared.loadProducts() + await StoreManager.shared.updateEntitlements() + + // 5. App is now usable isBootstrapping = false - // 5. Background: Try to refresh from CloudKit (non-blocking) + // 6. Background: Try to refresh from CloudKit (non-blocking) Task.detached(priority: .background) { await self.performBackgroundSync(context: context) await MainActor.run { diff --git a/SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift b/SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift index 351e70f..2a5b92a 100644 --- a/SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift +++ b/SportsTimeTests/Features/Progress/GamesHistoryViewModelTests.swift @@ -27,16 +27,18 @@ final class GamesHistoryViewModelTests: XCTestCase { stadiumId: "stadium-1", stadiumNameAtVisit: "Stadium 2026", visitDate: Calendar.current.date(from: DateComponents(year: 2026, month: 6, day: 15))!, + sport: .mlb, visitType: .game, - dataSource: .manual + dataSource: .fullyManual ) let visit2025 = StadiumVisit( stadiumId: "stadium-2", stadiumNameAtVisit: "Stadium 2025", visitDate: Calendar.current.date(from: DateComponents(year: 2025, month: 6, day: 15))!, + sport: .mlb, visitType: .game, - dataSource: .manual + dataSource: .fullyManual ) modelContext.insert(visit2026) @@ -60,8 +62,9 @@ final class GamesHistoryViewModelTests: XCTestCase { stadiumId: "yankee-stadium", stadiumNameAtVisit: "Yankee Stadium", visitDate: Date(), + sport: .mlb, visitType: .game, - dataSource: .manual + dataSource: .fullyManual ) modelContext.insert(mlbVisit) @@ -83,16 +86,18 @@ final class GamesHistoryViewModelTests: XCTestCase { stadiumId: "stadium-1", stadiumNameAtVisit: "Old Stadium", visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago + sport: .mlb, visitType: .game, - dataSource: .manual + dataSource: .fullyManual ) let newVisit = StadiumVisit( stadiumId: "stadium-2", stadiumNameAtVisit: "New Stadium", visitDate: Date(), + sport: .mlb, visitType: .game, - dataSource: .manual + dataSource: .fullyManual ) modelContext.insert(oldVisit) diff --git a/SportsTimeTests/Features/Progress/VisitListTests.swift b/SportsTimeTests/Features/Progress/VisitListTests.swift index 2d232d2..5ad214b 100644 --- a/SportsTimeTests/Features/Progress/VisitListTests.swift +++ b/SportsTimeTests/Features/Progress/VisitListTests.swift @@ -29,24 +29,27 @@ final class VisitListTests: XCTestCase { stadiumId: stadiumId, stadiumNameAtVisit: "Yankee Stadium", visitDate: Date().addingTimeInterval(-86400 * 30), // 30 days ago + sport: .mlb, visitType: .game, - dataSource: .manual + dataSource: .fullyManual ) let visit2 = StadiumVisit( stadiumId: stadiumId, stadiumNameAtVisit: "Yankee Stadium", visitDate: Date().addingTimeInterval(-86400 * 7), // 7 days ago + sport: .mlb, visitType: .game, - dataSource: .manual + dataSource: .fullyManual ) let visit3 = StadiumVisit( stadiumId: stadiumId, stadiumNameAtVisit: "Yankee Stadium", visitDate: Date(), // today + sport: .mlb, visitType: .tour, - dataSource: .manual + dataSource: .fullyManual ) modelContext.insert(visit1) @@ -77,8 +80,9 @@ final class VisitListTests: XCTestCase { stadiumId: stadium1, stadiumNameAtVisit: "Yankee Stadium", visitDate: Date().addingTimeInterval(Double(-i * 86400)), + sport: .mlb, visitType: .game, - dataSource: .manual + dataSource: .fullyManual ) modelContext.insert(visit) } @@ -87,8 +91,9 @@ final class VisitListTests: XCTestCase { stadiumId: stadium2, stadiumNameAtVisit: "Fenway Park", visitDate: Date(), + sport: .mlb, visitType: .game, - dataSource: .manual + dataSource: .fullyManual ) modelContext.insert(fenwayVisit) try modelContext.save() diff --git a/SportsTimeTests/Store/ProFeatureTests.swift b/SportsTimeTests/Store/ProFeatureTests.swift new file mode 100644 index 0000000..6c58dc7 --- /dev/null +++ b/SportsTimeTests/Store/ProFeatureTests.swift @@ -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) + } +} diff --git a/SportsTimeTests/Store/ProGateTests.swift b/SportsTimeTests/Store/ProGateTests.swift new file mode 100644 index 0000000..25958e1 --- /dev/null +++ b/SportsTimeTests/Store/ProGateTests.swift @@ -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 + } +} diff --git a/SportsTimeTests/Store/StoreErrorTests.swift b/SportsTimeTests/Store/StoreErrorTests.swift new file mode 100644 index 0000000..5bee328 --- /dev/null +++ b/SportsTimeTests/Store/StoreErrorTests.swift @@ -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")) + } +} diff --git a/SportsTimeTests/Store/StoreManagerTests.swift b/SportsTimeTests/Store/StoreManagerTests.swift new file mode 100644 index 0000000..6d4b730 --- /dev/null +++ b/SportsTimeTests/Store/StoreManagerTests.swift @@ -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) + } +} diff --git a/SportsTimeTests/Trip/TripWizardViewModelTests.swift b/SportsTimeTests/Trip/TripWizardViewModelTests.swift index 1eaceb7..df2d677 100644 --- a/SportsTimeTests/Trip/TripWizardViewModelTests.swift +++ b/SportsTimeTests/Trip/TripWizardViewModelTests.swift @@ -101,7 +101,7 @@ final class TripWizardViewModelTests: XCTestCase { viewModel.selectedRegions = [.east, .central] viewModel.hasSetRoutePreference = true viewModel.hasSetRepeatCities = true - viewModel.mustStopLocations = [LocationInput(name: "Test", coordinates: nil)] + viewModel.mustStopLocations = [LocationInput(name: "Test", coordinate: nil)] // Change planning mode viewModel.planningMode = .locations @@ -176,7 +176,7 @@ final class TripWizardViewModelTests: XCTestCase { func test_mustStopLocations_canBeAdded() { let viewModel = TripWizardViewModel() - let location = LocationInput(name: "Chicago, IL", coordinates: nil) + let location = LocationInput(name: "Chicago, IL", coordinate: nil) viewModel.mustStopLocations.append(location) diff --git a/SportsTimeTests/TripOptionsGroupingTests.swift b/SportsTimeTests/TripOptionsGroupingTests.swift index 218851a..823c16d 100644 --- a/SportsTimeTests/TripOptionsGroupingTests.swift +++ b/SportsTimeTests/TripOptionsGroupingTests.swift @@ -4,29 +4,36 @@ // import Testing +import Foundation +import CoreLocation @testable import SportsTime struct TripOptionsGroupingTests { + // Helper to create mock ItineraryStop + private func makeStop(city: String, games: [String] = []) -> ItineraryStop { + ItineraryStop( + city: city, + state: "XX", + coordinate: CLLocationCoordinate2D(latitude: 0, longitude: 0), + games: games, + arrivalDate: Date(), + departureDate: Date(), + location: LocationInput(name: city, coordinate: nil), + firstGameStart: nil + ) + } + // Helper to create mock ItineraryOption private func makeOption(stops: [(city: String, games: [String])], totalMiles: Double = 500) -> ItineraryOption { - let tripStops = stops.map { stopData in - TripStop( - city: stopData.city, - state: "XX", - coordinate: .init(latitude: 0, longitude: 0), - games: stopData.games, - arrivalDate: Date(), - departureDate: Date(), - travelFromPrevious: nil - ) - } + let itineraryStops = stops.map { makeStop(city: $0.city, games: $0.games) } return ItineraryOption( - id: UUID().uuidString, - stops: tripStops, - totalDistanceMiles: totalMiles, + rank: 1, + stops: itineraryStops, + travelSegments: [], totalDrivingHours: totalMiles / 60, - score: 1.0 + totalDistanceMiles: totalMiles, + geographicRationale: "Test" ) }