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>
283 lines
9.1 KiB
Swift
283 lines
9.1 KiB
Swift
//
|
|
// SettingsView.swift
|
|
// SportsTime
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
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
|
|
|
|
// Sports Preferences
|
|
sportsSection
|
|
|
|
// Travel Preferences
|
|
travelSection
|
|
|
|
// About
|
|
aboutSection
|
|
|
|
// Reset
|
|
resetSection
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.themedBackground()
|
|
.alert("Reset Settings", isPresented: $showResetConfirmation) {
|
|
Button("Cancel", role: .cancel) { }
|
|
Button("Reset", role: .destructive) {
|
|
viewModel.resetToDefaults()
|
|
}
|
|
} message: {
|
|
Text("This will reset all settings to their default values.")
|
|
}
|
|
}
|
|
|
|
// MARK: - Theme Section
|
|
|
|
private var themeSection: some View {
|
|
Section {
|
|
ForEach(AppTheme.allCases) { theme in
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.2)) {
|
|
viewModel.selectedTheme = theme
|
|
}
|
|
} label: {
|
|
HStack(spacing: 12) {
|
|
// Color preview circles
|
|
HStack(spacing: -6) {
|
|
ForEach(Array(theme.previewColors.enumerated()), id: \.offset) { _, color in
|
|
Circle()
|
|
.fill(color)
|
|
.frame(width: 24, height: 24)
|
|
.overlay(
|
|
Circle()
|
|
.stroke(Color.primary.opacity(0.1), lineWidth: 1)
|
|
)
|
|
}
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(theme.displayName)
|
|
.font(.body)
|
|
.foregroundStyle(.primary)
|
|
Text(theme.description)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
if viewModel.selectedTheme == theme {
|
|
Image(systemName: "checkmark.circle.fill")
|
|
.foregroundStyle(Theme.warmOrange)
|
|
.font(.title3)
|
|
}
|
|
}
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
} header: {
|
|
Text("Theme")
|
|
} footer: {
|
|
Text("Choose a color scheme for the app.")
|
|
}
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
}
|
|
|
|
// MARK: - Sports Section
|
|
|
|
private var sportsSection: some View {
|
|
Section {
|
|
ForEach(Sport.supported) { sport in
|
|
Toggle(isOn: Binding(
|
|
get: { viewModel.selectedSports.contains(sport) },
|
|
set: { _ in viewModel.toggleSport(sport) }
|
|
)) {
|
|
Label {
|
|
Text(sport.displayName)
|
|
} icon: {
|
|
Image(systemName: sport.iconName)
|
|
.foregroundStyle(sportColor(for: sport))
|
|
}
|
|
}
|
|
}
|
|
} header: {
|
|
Text("Favorite Sports")
|
|
} footer: {
|
|
Text("Selected sports will be shown by default in schedules and trip planning.")
|
|
}
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
}
|
|
|
|
// MARK: - Travel Section
|
|
|
|
private var travelSection: some View {
|
|
Section {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
HStack {
|
|
Text("Max Driving Per Day")
|
|
Spacer()
|
|
Text("\(viewModel.maxDrivingHoursPerDay) hours")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
Slider(
|
|
value: Binding(
|
|
get: { Double(viewModel.maxDrivingHoursPerDay) },
|
|
set: { viewModel.maxDrivingHoursPerDay = Int($0) }
|
|
),
|
|
in: 2...12,
|
|
step: 1
|
|
)
|
|
}
|
|
|
|
} header: {
|
|
Text("Travel Preferences")
|
|
} footer: {
|
|
Text("Trips will be optimized to keep daily driving within this limit.")
|
|
}
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
}
|
|
|
|
// MARK: - About Section
|
|
|
|
private var aboutSection: some View {
|
|
Section {
|
|
HStack {
|
|
Text("Version")
|
|
Spacer()
|
|
Text("\(viewModel.appVersion) (\(viewModel.buildNumber))")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Link(destination: URL(string: "https://88oakapps.com/privacy")!) {
|
|
Label("Privacy Policy", systemImage: "hand.raised")
|
|
}
|
|
|
|
Link(destination: URL(string: "https://88oakapps.com/terms")!) {
|
|
Label("Terms of Service", systemImage: "doc.text")
|
|
}
|
|
|
|
Link(destination: URL(string: "mailto:support@88oakapps.com")!) {
|
|
Label("Contact Support", systemImage: "envelope")
|
|
}
|
|
} header: {
|
|
Text("About")
|
|
}
|
|
.listRowBackground(Theme.cardBackground(colorScheme))
|
|
}
|
|
|
|
// MARK: - Reset Section
|
|
|
|
private var resetSection: some View {
|
|
Section {
|
|
Button(role: .destructive) {
|
|
showResetConfirmation = true
|
|
} label: {
|
|
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
|
|
}
|
|
}
|
|
.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 {
|
|
sport.themeColor
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
SettingsView()
|
|
}
|
|
}
|