Files
Sportstime/SportsTime/Features/Home/Views/HomeView.swift
Trey t c94e373e33 fix: comprehensive codebase hardening — crashes, silent failures, performance, and security
Fixes ~95 issues from deep audit across 12 categories in 82 files:

- Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in
  DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test
  bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files
- Silent failure elimination: all 34 try? sites replaced with do/try/catch +
  logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService,
  CanonicalModels, CKModels, SportsTimeApp, and more)
- Performance: cached DateFormatters (7 files), O(1) team lookups via
  AppDataProvider, achievement definition dictionary, AnimatedBackground
  consolidated from 19 Tasks to 1, task cancellation in SharePreviewView
- Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard,
  @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix
- Planning engine: game end time in travel feasibility, state-aware city
  normalization, exact city matching, DrivingConstraints parameter propagation
- IAP: unknown subscription states → expired, unverified transaction logging,
  entitlements updated before paywall dismiss, restore visible to all users
- Security: API key to Info.plist lookup, filename sanitization in PDF export,
  honest User-Agent, removed stale "Feels" analytics super properties
- Navigation: consolidated competing navigationDestination, boolean → value-based
- Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat
- Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote
  fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel
  MKDirections, Sendable-safe POI struct

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-27 17:03:09 -06:00

856 lines
30 KiB
Swift

//
// HomeView.swift
// SportsTime
//
import SwiftUI
import SwiftData
struct HomeView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@Query(sort: \SavedTrip.updatedAt, order: .reverse) private var savedTrips: [SavedTrip]
@State private var showNewTrip = false
@State private var selectedSport: Sport?
@State private var selectedTab = 0
@State private var suggestedTripsGenerator = SuggestedTripsGenerator()
@State private var selectedSuggestedTrip: SuggestedTrip?
@State private var displayedTips: [PlanningTip] = []
@State private var showProPaywall = false
#if DEBUG
@State private var marketingAutoScroll = false
#endif
var body: some View {
TabView(selection: $selectedTab) {
// Home Tab
NavigationStack {
AdaptiveHomeContent(
showNewTrip: $showNewTrip,
selectedTab: $selectedTab,
selectedSuggestedTrip: $selectedSuggestedTrip,
marketingAutoScroll: marketingAutoScrollBinding,
savedTrips: savedTrips,
suggestedTripsGenerator: suggestedTripsGenerator,
displayedTips: displayedTips
)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showNewTrip = true
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Theme.warmOrange)
}
.accessibilityLabel("Create new trip")
.accessibilityIdentifier("home.createNewTripButton")
}
}
}
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
.accessibilityIdentifier("tab.home")
// Schedule Tab
NavigationStack {
ScheduleListView()
}
.tabItem {
Label("Schedule", systemImage: "calendar")
}
.tag(1)
.accessibilityIdentifier("tab.schedule")
// My Trips Tab
NavigationStack {
SavedTripsListView(trips: savedTrips)
}
.tabItem {
Label("My Trips", systemImage: "suitcase.fill")
}
.tag(2)
.accessibilityIdentifier("tab.myTrips")
// Progress Tab
NavigationStack {
if StoreManager.shared.isPro {
ProgressTabView()
} else {
ProLockedView(feature: .progressTracking) {
showProPaywall = true
}
}
}
.tabItem {
Label("Progress", systemImage: "chart.bar.fill")
}
.tag(3)
.accessibilityIdentifier("tab.progress")
// Settings Tab
NavigationStack {
SettingsView()
}
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(4)
.accessibilityIdentifier("tab.settings")
}
.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)
#if DEBUG
if newTab == 0 && UserDefaults.standard.bool(forKey: "marketingVideoMode") {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
marketingAutoScroll = true
}
}
#endif
}
.sheet(isPresented: $showNewTrip) {
TripWizardView()
.environment(\.isDemoMode, ProcessInfo.isDemoMode)
}
.onChange(of: showNewTrip) { _, isShowing in
if !isShowing {
selectedSport = nil
}
}
.task {
if suggestedTripsGenerator.suggestedTrips.isEmpty && !suggestedTripsGenerator.isLoading {
await suggestedTripsGenerator.generateTrips()
}
}
.onAppear {
let tabNames = ["Home", "Schedule", "My Trips", "Progress", "Settings"]
let activeTabName = selectedTab < tabNames.count ? tabNames[selectedTab] : "Unknown"
AnalyticsManager.shared.trackScreen(activeTabName)
if displayedTips.isEmpty {
displayedTips = PlanningTips.random(3)
}
}
.sheet(item: $selectedSuggestedTrip) { suggestedTrip in
NavigationStack {
TripDetailView(trip: suggestedTrip.trip)
}
}
.sheet(isPresented: $showProPaywall) {
PaywallView(source: "home_progress_gate")
}
}
private var marketingAutoScrollBinding: Binding<Bool> {
#if DEBUG
return $marketingAutoScroll
#else
return .constant(false)
#endif
}
// MARK: - Hero Card
private var heroCard: some View {
VStack(spacing: Theme.Spacing.lg) {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Adventure Awaits")
.font(.largeTitle)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Plan your ultimate sports road trip. Visit stadiums, catch games, and create unforgettable memories.")
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
.fixedSize(horizontal: false, vertical: true)
}
.frame(maxWidth: .infinity, alignment: .leading)
Button {
showNewTrip = true
} label: {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: "map.fill")
Text("Start Planning")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.pressableStyle()
.glowEffect(color: Theme.warmOrange, radius: 12)
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.xlarge))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.xlarge)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 15, y: 8)
}
// MARK: - Suggested Trips
@ViewBuilder
private var suggestedTripsSection: some View {
if suggestedTripsGenerator.isLoading {
LoadingTripsView(message: suggestedTripsGenerator.loadingMessage)
} else if !suggestedTripsGenerator.suggestedTrips.isEmpty {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
// Header with refresh button
HStack {
Text("Featured Trips")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Button {
Task {
await suggestedTripsGenerator.refreshTrips()
}
} label: {
Image(systemName: "arrow.clockwise")
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}
.minimumHitTarget()
.accessibilityLabel("Refresh trips")
.accessibilityHint("Fetches the latest featured trip recommendations")
}
.padding(.horizontal, Theme.Spacing.md)
// Horizontal carousel grouped by region
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.lg) {
ForEach(suggestedTripsGenerator.tripsByRegion, id: \.region) { regionGroup in
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
// Region header
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: regionGroup.region.iconName)
.font(.caption)
.accessibilityHidden(true)
Text(regionGroup.region.shortName)
.font(.subheadline)
}
.foregroundStyle(Theme.textSecondary(colorScheme))
// Trip cards for this region
HStack(spacing: Theme.Spacing.md) {
ForEach(regionGroup.trips) { suggestedTrip in
Button {
AnalyticsManager.shared.track(.suggestedTripTapped(
region: regionGroup.region.shortName,
stopCount: suggestedTrip.trip.stops.count
))
selectedSuggestedTrip = suggestedTrip
} label: {
SuggestedTripCard(suggestedTrip: suggestedTrip)
}
.buttonStyle(.plain)
}
}
}
}
}
}
.contentMargins(.horizontal, Theme.Spacing.md, for: .scrollContent)
}
} else if let error = suggestedTripsGenerator.error {
// Error state
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Featured Trips")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.orange)
.accessibilityLabel("Error loading trips")
Text(error)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Button("Retry") {
Task {
await suggestedTripsGenerator.generateTrips()
}
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.padding(.horizontal, Theme.Spacing.md)
}
}
// MARK: - Saved Trips
private var savedTripsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Text("Recent Trips")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Button {
selectedTab = 2
} label: {
HStack(spacing: 4) {
Text("See All")
Image(systemName: "chevron.right")
.font(.caption)
.accessibilityHidden(true)
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}
}
ForEach(Array(savedTrips.prefix(3).enumerated()), id: \.element.id) { index, savedTrip in
if let trip = savedTrip.trip {
SavedTripCard(savedTrip: savedTrip, trip: trip)
.staggeredAnimation(index: index, delay: 0.05)
}
}
}
}
// MARK: - Tips
private var tipsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Planning Tips")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
VStack(spacing: Theme.Spacing.xs) {
ForEach(displayedTips) { tip in
TipRow(icon: tip.icon, title: tip.title, subtitle: tip.subtitle)
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
.onAppear {
if displayedTips.isEmpty {
displayedTips = PlanningTips.random(3)
}
}
}
}
// MARK: - Supporting Views
struct SavedTripCard: View {
let savedTrip: SavedTrip
let trip: Trip
@Environment(\.colorScheme) private var colorScheme
var body: some View {
NavigationLink {
TripDetailView(trip: trip, games: savedTrip.games, allowCustomItems: true)
} label: {
HStack(spacing: Theme.Spacing.md) {
// Route preview icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 44, height: 44)
Image(systemName: "map.fill")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
Text(trip.displayName)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(trip.formattedDateRange)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
HStack(spacing: Theme.Spacing.sm) {
HStack(spacing: 4) {
Image(systemName: "mappin")
.font(.caption2)
.accessibilityHidden(true)
Text("\(trip.stops.count) cities")
}
HStack(spacing: 4) {
Image(systemName: "sportscourt")
.font(.caption2)
.accessibilityHidden(true)
Text("\(trip.totalGames) games")
}
}
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.accessibilityElement(children: .combine)
}
.buttonStyle(.plain)
}
}
struct TipRow: View {
let icon: String
let title: String
let subtitle: String
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: Theme.Spacing.sm) {
ZStack {
Circle()
.fill(Theme.routeGold.opacity(0.15))
.frame(width: 36, height: 36)
Image(systemName: icon)
.font(.subheadline)
.foregroundStyle(Theme.routeGold)
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(subtitle)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
}
}
}
// MARK: - Saved Trips List View
struct SavedTripsListView: View {
let trips: [SavedTrip]
@Environment(\.colorScheme) private var colorScheme
@State private var polls: [TripPoll] = []
@State private var isLoadingPolls = false
@State private var hasLoadedPolls = false
@State private var showCreatePoll = false
@State private var selectedPoll: TripPoll?
#if DEBUG
@State private var showDebugPoll = false
#endif
@State private var sortedTrips: [SavedTrip] = []
/// Trips as domain objects for poll creation
private var tripsForPollCreation: [Trip] {
trips.compactMap { $0.trip }
}
var body: some View {
ScrollView {
LazyVStack(spacing: Theme.Spacing.lg) {
// Polls Section
pollsSection
// Trips Section
tripsSection
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.onChange(of: trips, initial: true) { _, newTrips in
sortedTrips = newTrips.sorted { ($0.trip?.stops.count ?? 0) > ($1.trip?.stops.count ?? 0) }
}
.task {
guard !hasLoadedPolls else { return }
hasLoadedPolls = true
await loadPolls()
}
.refreshable {
await loadPolls()
}
.sheet(isPresented: $showCreatePoll) {
PollCreationView(trips: tripsForPollCreation) { poll in
polls.insert(poll, at: 0)
}
}
.navigationDestination(for: TripPoll.self) { poll in
PollDetailView(pollId: poll.id)
}
#if DEBUG
.sheet(isPresented: $showDebugPoll) {
NavigationStack {
DebugPollPreviewView()
}
}
#endif
}
// MARK: - Polls Section
@ViewBuilder
private var pollsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Text("Group Polls")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
if trips.count >= 2 {
Button {
showCreatePoll = true
} label: {
Image(systemName: "plus.circle")
.foregroundStyle(Theme.warmOrange)
}
.accessibilityLabel("Create poll")
}
}
if isLoadingPolls && polls.isEmpty {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else if polls.isEmpty {
#if DEBUG
// Debug sample poll
Button {
showDebugPoll = true
} label: {
PollRowCard(poll: DebugShareExporter.buildSamplePoll())
}
.buttonStyle(.plain)
#else
emptyPollsCard
#endif
} else {
ForEach(polls) { poll in
NavigationLink(value: poll) {
PollRowCard(poll: poll)
}
.buttonStyle(.plain)
}
}
}
}
@ViewBuilder
private var emptyPollsCard: some View {
VStack(spacing: Theme.Spacing.sm) {
Image(systemName: "person.3")
.font(.title)
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
Text("No group polls yet")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
if trips.count >= 2 {
Text("Create a poll to let friends vote on trip options")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.multilineTextAlignment(.center)
} else {
Text("Save at least 2 trips to create a poll")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.multilineTextAlignment(.center)
}
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
// MARK: - Trips Section
@ViewBuilder
private var tripsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Saved Trips")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
if trips.isEmpty {
VStack(spacing: 16) {
Image(systemName: "suitcase")
.font(.largeTitle)
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
Text("No Saved Trips")
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Browse featured trips on the Home tab or create your own to get started.")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.xl)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.accessibilityIdentifier("myTrips.emptyState")
} else {
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
if let trip = savedTrip.trip {
NavigationLink {
TripDetailView(trip: trip, games: savedTrip.games, allowCustomItems: true)
} label: {
SavedTripListRow(trip: trip)
}
.buttonStyle(.plain)
.accessibilityIdentifier("myTrips.trip.\(index)")
.staggeredAnimation(index: index)
}
}
}
}
}
// MARK: - Actions
private func loadPolls() async {
isLoadingPolls = true
do {
polls = try await PollService.shared.fetchMyPolls()
} catch {
#if DEBUG
print("⚠️ [HomeView] Failed to load polls: \(error)")
#endif
polls = []
}
isLoadingPolls = false
}
}
// MARK: - Poll Row Card
private struct PollRowCard: View {
let poll: TripPoll
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: Theme.Spacing.md) {
// Icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 44, height: 44)
Image(systemName: "chart.bar.doc.horizontal")
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(poll.title)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: Theme.Spacing.sm) {
Label("\(poll.tripSnapshots.count) trips", systemImage: "map")
Text("")
Text(poll.shareCode)
.fontWeight(.semibold)
.foregroundStyle(Theme.warmOrange)
}
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 6, y: 3)
}
}
struct SavedTripListRow: View {
let trip: Trip
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: Theme.Spacing.md) {
// Route preview
VStack(spacing: 4) {
ForEach(0..<min(3, trip.stops.count), id: \.self) { i in
Circle()
.fill(Theme.warmOrange.opacity(Double(3 - i) / 3))
.frame(width: 8, height: 8)
if i < min(2, trip.stops.count - 1) {
Rectangle()
.fill(Theme.routeGold.opacity(0.5))
.frame(width: 2, height: 8)
}
}
}
.frame(width: 20)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(trip.displayName)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(trip.formattedDateRange)
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
// Route preview strip
if !trip.stops.isEmpty {
RoutePreviewStrip(cities: trip.stops.map { $0.city })
}
// Stats
HStack(spacing: Theme.Spacing.md) {
StatPill(icon: "mappin.circle", value: "\(trip.stops.count) cities")
StatPill(icon: "sportscourt", value: "\(trip.totalGames) games")
}
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.accessibilityHidden(true)
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 8, y: 4)
.accessibilityElement(children: .combine)
}
}
// 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(.largeTitle)
.foregroundStyle(Theme.warmOrange)
.accessibilityHidden(true)
}
.accessibilityLabel("Pro feature locked")
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))
.accessibilityElement(children: .combine)
}
.padding(.horizontal, Theme.Spacing.xl)
Spacer()
Spacer()
}
.themedBackground()
}
}
#Preview {
HomeView()
.modelContainer(for: SavedTrip.self, inMemory: true)
}