// // 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 { #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 { // Silently fail - polls just won't show } 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.. 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) }