Add 22 new UI tests across 8 test files covering Home, Schedule, Progress, Settings, TabNavigation, TripSaving, and TripOptions. Add accessibility identifiers to 11 view files for test element discovery. Fix sport chip assertion logic (all sports start selected, tap deselects), scroll container issues on iOS 26 nested ScrollViews, toggle interaction, and delete trip flow. Update QA coverage map from 32 to 54 automated test cases. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
853 lines
30 KiB
Swift
853 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
|
|
|
|
/// 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 {
|
|
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..<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)
|
|
}
|