Files
Sportstime/SportsTime/Features/Home/Views/HomeView.swift
Trey t 2ad458bffd refactor: TripDetailView loads games on demand, improve poll UI
- Refactor TripDetailView to fetch games from AppDataProvider when not
  provided, adding loading state indicator for better UX
- Update all callers (25+ HomeContent variants, TripOptionsView, HomeView)
  to use simpler TripDetailView(trip:) initializer
- Fix PollDetailView sheet issue by using sheet(item:) instead of
  sheet(isPresented:) to prevent blank screen on first tap
- Improve PollDetailView UI with Theme styling, icons, and better
  visual hierarchy for share code, voting status, and results sections

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 11:31:05 -06:00

754 lines
26 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
var body: some View {
TabView(selection: $selectedTab) {
// Home Tab
NavigationStack {
AdaptiveHomeContent(
showNewTrip: $showNewTrip,
selectedTab: $selectedTab,
selectedSuggestedTrip: $selectedSuggestedTrip,
savedTrips: savedTrips,
suggestedTripsGenerator: suggestedTripsGenerator,
displayedTips: displayedTips
)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showNewTrip = true
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Theme.warmOrange)
}
}
}
}
.tabItem {
Label("Home", systemImage: "house.fill")
}
.tag(0)
// Schedule Tab
NavigationStack {
ScheduleListView()
}
.tabItem {
Label("Schedule", systemImage: "calendar")
}
.tag(1)
// My Trips Tab
NavigationStack {
SavedTripsListView(trips: savedTrips)
}
.tabItem {
Label("My Trips", systemImage: "suitcase.fill")
}
.tag(2)
// Progress Tab
NavigationStack {
if StoreManager.shared.isPro {
ProgressTabView()
} else {
ProLockedView(feature: .progressTracking) {
showProPaywall = true
}
}
}
.tabItem {
Label("Progress", systemImage: "chart.bar.fill")
}
.tag(3)
// Settings Tab
NavigationStack {
SettingsView()
}
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(4)
}
.tint(Theme.warmOrange)
.sheet(isPresented: $showNewTrip) {
TripWizardView()
}
.onChange(of: showNewTrip) { _, isShowing in
if !isShowing {
selectedSport = nil
}
}
.task {
if suggestedTripsGenerator.suggestedTrips.isEmpty && !suggestedTripsGenerator.isLoading {
await suggestedTripsGenerator.generateTrips()
}
}
.sheet(item: $selectedSuggestedTrip) { suggestedTrip in
NavigationStack {
TripDetailView(trip: suggestedTrip.trip)
}
}
.sheet(isPresented: $showProPaywall) {
PaywallView()
}
}
// 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)
}
}
// 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)
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 {
selectedSuggestedTrip = suggestedTrip
} label: {
SuggestedTripCard(suggestedTrip: suggestedTrip)
}
.buttonStyle(.plain)
}
}
}
}
}
.padding(.horizontal, 1) // Prevent clipping
}
}
} 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)
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))
}
}
}
// 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)
}
.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)
} 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)
}
VStack(alignment: .leading, spacing: 4) {
Text(trip.name)
.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)
Text("\(trip.stops.count) cities")
}
HStack(spacing: 4) {
Image(systemName: "sportscourt")
.font(.caption2)
Text("\(trip.totalGames) games")
}
}
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
Spacer()
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.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)
}
}
.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)
}
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 showCreatePoll = false
@State private var selectedPoll: TripPoll?
/// Trips sorted by most cities (stops) first
private var sortedTrips: [SavedTrip] {
trips.sorted { ($0.trip?.stops.count ?? 0) > ($1.trip?.stops.count ?? 0) }
}
/// 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()
.task {
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)
}
}
// 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)
}
}
}
if isLoadingPolls {
ProgressView()
.frame(maxWidth: .infinity, alignment: .center)
.padding()
} else if polls.isEmpty {
emptyPollsCard
} 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))
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)
.foregroundColor(.secondary)
Text("No Saved Trips")
.font(.headline)
Text("Browse featured trips on the Home tab or create your own to get started.")
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.xl)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
} else {
ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in
if let trip = savedTrip.trip {
NavigationLink {
TripDetailView(trip: trip)
} label: {
SavedTripListRow(trip: trip)
}
.buttonStyle(.plain)
.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)
}
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))
}
.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)
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(trip.name)
.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))
}
.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)
}
}
// 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)
}