- Only load polls once on initial view appearance, not every tab switch - Only show spinner when there's no existing data (first load) - Subsequent refreshes update content in place without showing spinner Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
757 lines
26 KiB
Swift
757 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, 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)
|
|
}
|
|
|
|
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)
|
|
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 hasLoadedPolls = 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 {
|
|
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)
|
|
}
|
|
}
|
|
|
|
// 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 && polls.isEmpty {
|
|
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, games: savedTrip.games, allowCustomItems: true)
|
|
} 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.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))
|
|
}
|
|
.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)
|
|
}
|