Files
Sportstime/SportsTime/Features/Progress/Views/ProgressTabView.swift
Trey t 475f444288 refactor: extract reusable SportSelectorGrid component
Create unified sport selector grid used across Home (Quick Start),
Trip Creation, and Progress views. Removes duplicate button implementations
and ensures consistent grid layout with centered bottom row.

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

636 lines
23 KiB
Swift

//
// ProgressTabView.swift
// SportsTime
//
// Main view for stadium progress tracking with league selector and map.
//
import SwiftUI
import SwiftData
struct ProgressTabView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel = ProgressViewModel()
@State private var showVisitSheet = false
@State private var showPhotoImport = false
@State private var showShareSheet = false
@State private var selectedStadium: Stadium?
@State private var selectedVisitId: UUID?
@Query private var visits: [StadiumVisit]
var body: some View {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// League Selector
leagueSelector
.staggeredAnimation(index: 0)
// Progress Summary Card
progressSummaryCard
.staggeredAnimation(index: 1)
// Map View
ProgressMapView(
stadiums: viewModel.sportStadiums,
visitStatus: viewModel.stadiumVisitStatus,
selectedStadium: $selectedStadium
)
.frame(height: 300)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
.staggeredAnimation(index: 2)
// Stadium Lists
stadiumListsSection
.staggeredAnimation(index: 3)
// Achievements Teaser
achievementsSection
.staggeredAnimation(index: 4)
// Recent Visits
if !viewModel.recentVisits.isEmpty {
recentVisitsSection
.staggeredAnimation(index: 5)
}
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
showShareSheet = true
} label: {
Image(systemName: "square.and.arrow.up")
.foregroundStyle(Theme.warmOrange)
}
}
ToolbarItem(placement: .primaryAction) {
Menu {
Button {
showVisitSheet = true
} label: {
Label("Manual Entry", systemImage: "pencil")
}
Button {
showPhotoImport = true
} label: {
Label("Import from Photos", systemImage: "photo.on.rectangle.angled")
}
} label: {
Image(systemName: "plus.circle.fill")
.font(.title2)
.foregroundStyle(Theme.warmOrange)
}
}
}
.task {
viewModel.configure(with: modelContext.container)
await viewModel.loadData()
}
.sheet(isPresented: $showVisitSheet) {
StadiumVisitSheet(initialSport: viewModel.selectedSport) { _ in
Task {
await viewModel.loadData()
}
}
}
.sheet(isPresented: $showPhotoImport) {
PhotoImportView()
.onDisappear {
Task {
await viewModel.loadData()
}
}
}
.sheet(item: $selectedStadium) { stadium in
StadiumDetailSheet(
stadium: stadium,
visitStatus: viewModel.stadiumVisitStatus[stadium.id] ?? .notVisited,
sport: viewModel.selectedSport,
onVisitLogged: {
Task {
await viewModel.loadData()
}
}
)
.presentationDetents([.medium])
}
.sheet(isPresented: $showShareSheet) {
ProgressShareView(progress: viewModel.leagueProgress)
}
}
// MARK: - League Selector
private var leagueSelector: some View {
SportSelectorGrid { sport in
SportProgressButton(
sport: sport,
isSelected: viewModel.selectedSport == sport,
progress: progressForSport(sport)
) {
withAnimation(Theme.Animation.spring) {
viewModel.selectSport(sport)
}
}
}
}
private func progressForSport(_ sport: Sport) -> Double {
let visitedCount = viewModel.visits.filter { $0.sportEnum == sport }.count
let total = LeagueStructure.stadiumCount(for: sport)
guard total > 0 else { return 0 }
return Double(min(visitedCount, total)) / Double(total)
}
// MARK: - Progress Summary Card
private var progressSummaryCard: some View {
let progress = viewModel.leagueProgress
return VStack(spacing: Theme.Spacing.lg) {
// Title and progress ring
HStack(alignment: .center, spacing: Theme.Spacing.lg) {
// Progress Ring
ZStack {
Circle()
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 8)
.frame(width: 80, height: 80)
Circle()
.trim(from: 0, to: progress.progressFraction)
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.frame(width: 80, height: 80)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.5), value: progress.progressFraction)
VStack(spacing: 0) {
Text("\(progress.visitedStadiums)")
.font(.title3)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("/\(progress.totalStadiums)")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(viewModel.selectedSport.displayName)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Stadium Quest")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
if progress.isComplete {
HStack(spacing: 4) {
Image(systemName: "checkmark.seal.fill")
Text("Complete!")
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
} else {
Text("\(progress.totalStadiums - progress.visitedStadiums) stadiums remaining")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
Spacer()
}
// Stats row
HStack(spacing: Theme.Spacing.lg) {
ProgressStatPill(
icon: "mappin.circle.fill",
value: "\(progress.visitedStadiums)",
label: "Visited"
)
ProgressStatPill(
icon: "circle.dotted",
value: "\(progress.totalStadiums - progress.visitedStadiums)",
label: "Remaining"
)
ProgressStatPill(
icon: "percent",
value: String(format: "%.0f%%", progress.completionPercentage),
label: "Complete"
)
}
}
.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: 10, y: 5)
}
// MARK: - Stadium Lists Section
private var stadiumListsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
// Visited Stadiums
if !viewModel.visitedStadiums.isEmpty {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Visited (\(viewModel.visitedStadiums.count))")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) {
ForEach(viewModel.visitedStadiums) { stadium in
StadiumChip(
stadium: stadium,
isVisited: true
) {
selectedStadium = stadium
}
}
}
}
}
}
// Unvisited Stadiums
if !viewModel.unvisitedStadiums.isEmpty {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "circle.dotted")
.foregroundStyle(Theme.textMuted(colorScheme))
Text("Not Yet Visited (\(viewModel.unvisitedStadiums.count))")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) {
ForEach(viewModel.unvisitedStadiums) { stadium in
StadiumChip(
stadium: stadium,
isVisited: false
) {
selectedStadium = stadium
}
}
}
}
}
}
}
}
// MARK: - Achievements Section
private var achievementsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Text("Achievements")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
NavigationLink {
AchievementsListView()
} label: {
HStack(spacing: 4) {
Text("View All")
Image(systemName: "chevron.right")
}
.font(.subheadline)
.foregroundStyle(Theme.warmOrange)
}
}
NavigationLink {
AchievementsListView()
} label: {
HStack(spacing: Theme.Spacing.md) {
// Trophy icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 50, height: 50)
Image(systemName: "trophy.fill")
.font(.system(size: 24))
.foregroundStyle(Theme.warmOrange)
}
VStack(alignment: .leading, spacing: 4) {
Text("Track Your Progress")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Earn badges for stadium visits")
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
Image(systemName: "chevron.right")
.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.warmOrange.opacity(0.3), lineWidth: 1)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Recent Visits Section
private var recentVisitsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Recent Visits")
.font(.title2)
.foregroundStyle(Theme.textPrimary(colorScheme))
ForEach(viewModel.recentVisits) { visitSummary in
if let stadiumVisit = visits.first(where: { $0.id == visitSummary.id }) {
NavigationLink {
VisitDetailView(visit: stadiumVisit, stadium: visitSummary.stadium)
} label: {
RecentVisitRow(visit: visitSummary)
}
.buttonStyle(.plain)
} else {
RecentVisitRow(visit: visitSummary)
}
}
}
}
}
// MARK: - Supporting Views
struct ProgressStatPill: View {
let icon: String
let value: String
let label: String
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: 4) {
HStack(spacing: 4) {
Image(systemName: icon)
.font(.caption)
Text(value)
.font(.body)
}
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(label)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.frame(maxWidth: .infinity)
}
}
struct StadiumChip: View {
let stadium: Stadium
let isVisited: Bool
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: action) {
HStack(spacing: Theme.Spacing.xs) {
if isVisited {
Image(systemName: "checkmark.circle.fill")
.font(.caption)
.foregroundStyle(.green)
}
VStack(alignment: .leading, spacing: 2) {
Text(stadium.name)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
.lineLimit(1)
Text(stadium.city)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs)
.background(Theme.cardBackground(colorScheme))
.clipShape(Capsule())
.overlay {
Capsule()
.stroke(isVisited ? Color.green.opacity(0.3) : Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
.buttonStyle(.plain)
}
}
struct RecentVisitRow: View {
let visit: VisitSummary
@Environment(\.colorScheme) private var colorScheme
var body: some View {
HStack(spacing: Theme.Spacing.md) {
// Sport icon
ZStack {
Circle()
.fill(visit.sport.themeColor.opacity(0.15))
.frame(width: 40, height: 40)
Image(systemName: visit.sport.iconName)
.foregroundStyle(visit.sport.themeColor)
}
VStack(alignment: .leading, spacing: 4) {
Text(visit.stadium.name)
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: Theme.Spacing.sm) {
Text(visit.shortDateDescription)
if let matchup = visit.matchup {
Text("")
Text(matchup)
}
}
.font(.subheadline)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
if visit.photoCount > 0 {
HStack(spacing: 4) {
Image(systemName: "photo")
Text("\(visit.photoCount)")
}
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
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)
}
}
}
struct StadiumDetailSheet: View {
let stadium: Stadium
let visitStatus: StadiumVisitStatus
let sport: Sport
var onVisitLogged: (() -> Void)?
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var showLogVisit = false
var body: some View {
NavigationStack {
VStack(spacing: Theme.Spacing.lg) {
// Stadium header
VStack(spacing: Theme.Spacing.sm) {
ZStack {
Circle()
.fill(sport.themeColor.opacity(0.15))
.frame(width: 80, height: 80)
Image(systemName: visitStatus.isVisited ? "checkmark.seal.fill" : sport.iconName)
.font(.largeTitle)
.foregroundStyle(visitStatus.isVisited ? .green : sport.themeColor)
}
Text(stadium.name)
.font(.headline)
.foregroundStyle(Theme.textPrimary(colorScheme))
.multilineTextAlignment(.center)
Text(stadium.fullAddress)
.font(.body)
.foregroundStyle(Theme.textSecondary(colorScheme))
if visitStatus.isVisited {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
Text("Visited \(visitStatus.visitCount) time\(visitStatus.visitCount == 1 ? "" : "s")")
}
.font(.subheadline)
.foregroundStyle(.green)
}
}
// Visit history if visited
if case .visited(let visits) = visitStatus {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Visit History")
.font(.body)
.foregroundStyle(Theme.textPrimary(colorScheme))
ForEach(visits.sorted(by: { $0.visitDate > $1.visitDate })) { visit in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(visit.shortDateDescription)
.font(.subheadline)
if let matchup = visit.matchup {
Text(matchup)
.font(.caption)
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
Spacer()
Text(visit.visitType.displayName)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
}
}
}
Spacer()
// Action button
Button {
showLogVisit = true
} label: {
HStack {
Image(systemName: visitStatus.isVisited ? "plus" : "checkmark.circle")
Text(visitStatus.isVisited ? "Log Another Visit" : "Log Visit")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.pressableStyle()
}
.padding(Theme.Spacing.lg)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
.sheet(isPresented: $showLogVisit) {
StadiumVisitSheet(
initialStadium: stadium,
initialSport: sport
) { _ in
onVisitLogged?()
dismiss()
}
}
}
}
}
#Preview {
NavigationStack {
ProgressTabView()
}
.modelContainer(for: StadiumVisit.self, inMemory: true)
}