Add Stadium Progress system and themed loading spinners

Stadium Progress & Achievements:
- Add StadiumVisit and Achievement SwiftData models
- Create Progress tab with interactive map view
- Implement photo-based visit import with GPS/date matching
- Add achievement badges (count-based, regional, journey)
- Create shareable progress cards for social media
- Add canonical data infrastructure (stadium identities, team aliases)
- Implement score resolution from free APIs (MLB, NBA, NHL stats)

UI Improvements:
- Add ThemedSpinner and ThemedSpinnerCompact components
- Replace all ProgressView() with themed spinners throughout app
- Fix sport selection state not persisting when navigating away

Bug Fixes:
- Fix Coast to Coast trips showing only 1 city (validation issue)
- Fix stadium progress showing 0/0 (filtering issue)
- Remove "Stadium Quest" title from progress view

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-08 20:20:03 -06:00
parent 2281440bf8
commit 92d808caf5
55 changed files with 14348 additions and 61 deletions

View File

@@ -0,0 +1,173 @@
//
// PhotoImportViewModel.swift
// SportsTime
//
// ViewModel for photo import flow - orchestrates extraction, matching, and import.
//
import Foundation
import SwiftUI
import PhotosUI
import SwiftData
import Photos
@MainActor @Observable
final class PhotoImportViewModel {
// State
var showingPicker = false
var isProcessing = false
var processedCount = 0
var totalCount = 0
// Results
var processedPhotos: [PhotoImportCandidate] = []
var confirmedImports: Set<UUID> = []
var selectedMatches: [UUID: GameMatchCandidate] = [:]
// Services
private let metadataExtractor = PhotoMetadataExtractor.shared
private let gameMatcher = GameMatcher.shared
// MARK: - Computed
var categorized: GameMatcher.CategorizedImports {
gameMatcher.categorizeImports(processedPhotos)
}
var hasConfirmedImports: Bool {
!confirmedImports.isEmpty
}
var confirmedCount: Int {
confirmedImports.count
}
// MARK: - Photo Processing
func processSelectedPhotos(_ items: [PhotosPickerItem]) async {
guard !items.isEmpty else { return }
isProcessing = true
totalCount = items.count
processedCount = 0
processedPhotos = []
confirmedImports = []
selectedMatches = [:]
// Load PHAssets from PhotosPickerItems
var assets: [PHAsset] = []
for item in items {
if let assetId = item.itemIdentifier {
let fetchResult = PHAsset.fetchAssets(withLocalIdentifiers: [assetId], options: nil)
if let asset = fetchResult.firstObject {
assets.append(asset)
}
}
processedCount += 1
}
// Extract metadata from all assets
let metadataList = await metadataExtractor.extractMetadata(from: assets)
// Process each photo through game matcher
processedCount = 0
for metadata in metadataList {
let candidate = await gameMatcher.processPhotoForImport(metadata: metadata)
processedPhotos.append(candidate)
// Auto-confirm high-confidence matches
if candidate.canAutoProcess {
confirmedImports.insert(candidate.id)
}
processedCount += 1
}
isProcessing = false
}
// MARK: - User Actions
func toggleConfirmation(for candidateId: UUID) {
if confirmedImports.contains(candidateId) {
confirmedImports.remove(candidateId)
} else {
confirmedImports.insert(candidateId)
}
}
func selectMatch(_ match: GameMatchCandidate, for candidateId: UUID) {
selectedMatches[candidateId] = match
confirmedImports.insert(candidateId)
}
func confirmAll() {
for candidate in processedPhotos {
if case .singleMatch = candidate.matchResult {
confirmedImports.insert(candidate.id)
} else if case .multipleMatches = candidate.matchResult,
selectedMatches[candidate.id] != nil {
confirmedImports.insert(candidate.id)
}
}
}
// MARK: - Import Creation
func createVisits(modelContext: ModelContext) async {
for candidate in processedPhotos {
guard confirmedImports.contains(candidate.id) else { continue }
// Get the match to use
let matchToUse: GameMatchCandidate?
switch candidate.matchResult {
case .singleMatch(let match):
matchToUse = match
case .multipleMatches:
matchToUse = selectedMatches[candidate.id]
case .noMatches:
matchToUse = nil
}
guard let match = matchToUse else { continue }
// Create the visit
let visit = StadiumVisit(
canonicalStadiumId: match.stadium.id.uuidString,
stadiumUUID: match.stadium.id,
stadiumNameAtVisit: match.stadium.name,
visitDate: match.game.dateTime,
sport: match.game.sport,
visitType: .game,
homeTeamName: match.homeTeam.fullName,
awayTeamName: match.awayTeam.fullName,
finalScore: nil,
scoreSource: nil,
dataSource: .automatic,
seatLocation: nil,
notes: nil,
photoLatitude: candidate.metadata.coordinates?.latitude,
photoLongitude: candidate.metadata.coordinates?.longitude,
photoCaptureDate: candidate.metadata.captureDate,
source: .photoImport
)
modelContext.insert(visit)
}
try? modelContext.save()
}
// MARK: - Reset
func reset() {
processedPhotos = []
confirmedImports = []
selectedMatches = [:]
isProcessing = false
processedCount = 0
totalCount = 0
}
}

View File

@@ -0,0 +1,204 @@
//
// ProgressViewModel.swift
// SportsTime
//
// ViewModel for stadium progress tracking and visualization.
//
import Foundation
import SwiftUI
import SwiftData
@MainActor
@Observable
final class ProgressViewModel {
// MARK: - State
var selectedSport: Sport = .mlb
var isLoading = false
var error: Error?
var errorMessage: String?
// MARK: - Data
private(set) var visits: [StadiumVisit] = []
private(set) var stadiums: [Stadium] = []
private(set) var teams: [Team] = []
// MARK: - Dependencies
private var modelContainer: ModelContainer?
private let dataProvider = AppDataProvider.shared
// MARK: - Computed Properties
/// Overall progress for the selected sport
var leagueProgress: LeagueProgress {
// Filter stadiums by sport directly (same as sportStadiums)
let sportStadiums = stadiums.filter { $0.sport == selectedSport }
let visitedStadiumIds = Set(
visits
.filter { $0.sportEnum == selectedSport }
.compactMap { visit -> UUID? in
// Match visit's canonical stadium ID to a stadium
stadiums.first { stadium in
stadium.id == visit.stadiumUUID
}?.id
}
)
let visited = sportStadiums.filter { visitedStadiumIds.contains($0.id) }
let remaining = sportStadiums.filter { !visitedStadiumIds.contains($0.id) }
return LeagueProgress(
sport: selectedSport,
totalStadiums: sportStadiums.count,
visitedStadiums: visited.count,
stadiumsVisited: visited,
stadiumsRemaining: remaining
)
}
/// Stadium visit status indexed by stadium ID
var stadiumVisitStatus: [UUID: StadiumVisitStatus] {
var statusMap: [UUID: StadiumVisitStatus] = [:]
// Group visits by stadium
let visitsByStadium = Dictionary(grouping: visits.filter { $0.sportEnum == selectedSport }) { $0.stadiumUUID }
for stadium in stadiums {
if let stadiumVisits = visitsByStadium[stadium.id], !stadiumVisits.isEmpty {
let summaries = stadiumVisits.map { visit in
VisitSummary(
id: visit.id,
stadium: stadium,
visitDate: visit.visitDate,
visitType: visit.visitType,
sport: selectedSport,
matchup: visit.matchupDescription,
score: visit.finalScore,
photoCount: visit.photoMetadata?.count ?? 0,
notes: visit.notes
)
}
statusMap[stadium.id] = .visited(visits: summaries)
} else {
statusMap[stadium.id] = .notVisited
}
}
return statusMap
}
/// Stadiums for the selected sport
var sportStadiums: [Stadium] {
stadiums.filter { $0.sport == selectedSport }
}
/// Visited stadiums for the selected sport
var visitedStadiums: [Stadium] {
leagueProgress.stadiumsVisited
}
/// Unvisited stadiums for the selected sport
var unvisitedStadiums: [Stadium] {
leagueProgress.stadiumsRemaining
}
/// Recent visits sorted by date
var recentVisits: [VisitSummary] {
visits
.sorted { $0.visitDate > $1.visitDate }
.prefix(10)
.compactMap { visit -> VisitSummary? in
guard let stadium = stadiums.first(where: { $0.id == visit.stadiumUUID }),
let sport = visit.sportEnum else {
return nil
}
return VisitSummary(
id: visit.id,
stadium: stadium,
visitDate: visit.visitDate,
visitType: visit.visitType,
sport: sport,
matchup: visit.matchupDescription,
score: visit.finalScore,
photoCount: visit.photoMetadata?.count ?? 0,
notes: visit.notes
)
}
}
// MARK: - Configuration
func configure(with container: ModelContainer) {
self.modelContainer = container
}
// MARK: - Actions
func loadData() async {
isLoading = true
error = nil
errorMessage = nil
do {
// Load stadiums and teams from data provider
if dataProvider.stadiums.isEmpty {
await dataProvider.loadInitialData()
}
stadiums = dataProvider.stadiums
teams = dataProvider.teams
// Load visits from SwiftData
if let container = modelContainer {
let context = ModelContext(container)
let descriptor = FetchDescriptor<StadiumVisit>(
sortBy: [SortDescriptor(\.visitDate, order: .reverse)]
)
visits = try context.fetch(descriptor)
}
} catch {
self.error = error
self.errorMessage = error.localizedDescription
}
isLoading = false
}
func selectSport(_ sport: Sport) {
selectedSport = sport
}
func clearError() {
error = nil
errorMessage = nil
}
// MARK: - Visit Management
func deleteVisit(_ visit: StadiumVisit) async throws {
guard let container = modelContainer else { return }
let context = ModelContext(container)
context.delete(visit)
try context.save()
// Reload data
await loadData()
}
// MARK: - Progress Card Generation
func progressCardData(includeUsername: Bool = false) -> ProgressCardData {
ProgressCardData(
sport: selectedSport,
progress: leagueProgress,
username: nil,
includeMap: true,
showDetailedStats: false
)
}
}

View File

@@ -0,0 +1,526 @@
//
// AchievementsListView.swift
// SportsTime
//
// Displays achievements gallery with earned, in-progress, and locked badges.
//
import SwiftUI
import SwiftData
struct AchievementsListView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@State private var achievements: [AchievementProgress] = []
@State private var isLoading = true
@State private var selectedCategory: AchievementCategory?
@State private var selectedAchievement: AchievementProgress?
var body: some View {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Summary header
achievementSummary
.staggeredAnimation(index: 0)
// Category filter
categoryFilter
.staggeredAnimation(index: 1)
// Achievements grid
achievementsGrid
.staggeredAnimation(index: 2)
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.navigationTitle("Achievements")
.task {
await loadAchievements()
}
.sheet(item: $selectedAchievement) { achievement in
AchievementDetailSheet(achievement: achievement)
.presentationDetents([.medium])
}
}
// MARK: - Achievement Summary
private var achievementSummary: some View {
let earned = achievements.filter { $0.isEarned }.count
let total = achievements.count
return HStack(spacing: Theme.Spacing.lg) {
// Trophy icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 70, height: 70)
Image(systemName: "trophy.fill")
.font(.system(size: 32))
.foregroundStyle(Theme.warmOrange)
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("\(earned) / \(total)")
.font(.system(size: Theme.FontSize.heroTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Achievements Earned")
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
if earned == total && total > 0 {
HStack(spacing: 4) {
Image(systemName: "star.fill")
Text("All achievements unlocked!")
}
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
.foregroundStyle(Theme.warmOrange)
}
}
Spacer()
}
.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: - Category Filter
private var categoryFilter: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: Theme.Spacing.sm) {
CategoryFilterButton(
title: "All",
icon: "square.grid.2x2",
isSelected: selectedCategory == nil
) {
withAnimation(Theme.Animation.spring) {
selectedCategory = nil
}
}
ForEach(AchievementCategory.allCases, id: \.self) { category in
CategoryFilterButton(
title: category.displayName,
icon: category.iconName,
isSelected: selectedCategory == category
) {
withAnimation(Theme.Animation.spring) {
selectedCategory = category
}
}
}
}
}
}
// MARK: - Achievements Grid
private var achievementsGrid: some View {
let filtered = filteredAchievements
return LazyVGrid(
columns: [GridItem(.flexible()), GridItem(.flexible())],
spacing: Theme.Spacing.md
) {
ForEach(filtered) { achievement in
AchievementCard(achievement: achievement)
.onTapGesture {
selectedAchievement = achievement
}
}
}
}
private var filteredAchievements: [AchievementProgress] {
guard let category = selectedCategory else {
return achievements.sorted { first, second in
// Earned first, then by progress
if first.isEarned != second.isEarned {
return first.isEarned
}
return first.progressPercentage > second.progressPercentage
}
}
return achievements.filter { $0.definition.category == category }
.sorted { first, second in
if first.isEarned != second.isEarned {
return first.isEarned
}
return first.progressPercentage > second.progressPercentage
}
}
// MARK: - Data Loading
private func loadAchievements() async {
isLoading = true
do {
let engine = AchievementEngine(modelContext: modelContext)
achievements = try await engine.getProgress()
} catch {
// Handle error silently, show empty state
achievements = []
}
isLoading = false
}
}
// MARK: - Category Filter Button
struct CategoryFilterButton: View {
let title: String
let icon: String
let isSelected: Bool
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: action) {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: icon)
.font(.system(size: 14))
Text(title)
.font(.system(size: Theme.FontSize.caption, weight: .medium))
}
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.background(isSelected ? Theme.warmOrange : Theme.cardBackground(colorScheme))
.foregroundStyle(isSelected ? .white : Theme.textPrimary(colorScheme))
.clipShape(Capsule())
.overlay {
Capsule()
.stroke(isSelected ? Color.clear : Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
.buttonStyle(.plain)
}
}
// MARK: - Achievement Card
struct AchievementCard: View {
let achievement: AchievementProgress
@Environment(\.colorScheme) private var colorScheme
var body: some View {
VStack(spacing: Theme.Spacing.sm) {
// Badge icon
ZStack {
Circle()
.fill(badgeBackgroundColor)
.frame(width: 60, height: 60)
Image(systemName: achievement.definition.iconName)
.font(.system(size: 28))
.foregroundStyle(badgeIconColor)
if !achievement.isEarned {
Circle()
.fill(.black.opacity(0.3))
.frame(width: 60, height: 60)
Image(systemName: "lock.fill")
.font(.system(size: 14))
.foregroundStyle(.white)
}
}
// Title
Text(achievement.definition.name)
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
.foregroundStyle(achievement.isEarned ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme))
.multilineTextAlignment(.center)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
// Progress or earned date
if achievement.isEarned {
if let earnedAt = achievement.earnedAt {
Text(earnedAt.formatted(date: .abbreviated, time: .omitted))
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.warmOrange)
}
} else {
// Progress bar
VStack(spacing: 4) {
ProgressView(value: achievement.progressPercentage)
.progressViewStyle(AchievementProgressStyle())
Text(achievement.progressText)
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
}
.padding(Theme.Spacing.md)
.frame(maxWidth: .infinity, minHeight: 170)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(achievement.isEarned ? Theme.warmOrange.opacity(0.5) : Theme.surfaceGlow(colorScheme), lineWidth: achievement.isEarned ? 2 : 1)
}
.shadow(color: Theme.cardShadow(colorScheme), radius: 5, y: 2)
.opacity(achievement.isEarned ? 1.0 : 0.7)
}
private var badgeBackgroundColor: Color {
if achievement.isEarned {
return categoryColor.opacity(0.2)
}
return Theme.cardBackgroundElevated(colorScheme)
}
private var badgeIconColor: Color {
if achievement.isEarned {
return categoryColor
}
return Theme.textMuted(colorScheme)
}
private var categoryColor: Color {
switch achievement.definition.category {
case .count:
return Theme.warmOrange
case .division:
return Theme.routeGold
case .conference:
return Theme.routeAmber
case .league:
return Color(hex: "FFD700") // Gold
case .journey:
return Color(hex: "9B59B6") // Purple
case .special:
return Color(hex: "E74C3C") // Red
}
}
}
// MARK: - Achievement Progress Style
struct AchievementProgressStyle: ProgressViewStyle {
@Environment(\.colorScheme) private var colorScheme
func makeBody(configuration: Configuration) -> some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 2)
.fill(Theme.cardBackgroundElevated(colorScheme))
.frame(height: 4)
RoundedRectangle(cornerRadius: 2)
.fill(Theme.warmOrange)
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 4)
}
}
.frame(height: 4)
}
}
// MARK: - Achievement Detail Sheet
struct AchievementDetailSheet: View {
let achievement: AchievementProgress
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
VStack(spacing: Theme.Spacing.xl) {
// Large badge
ZStack {
Circle()
.fill(badgeBackgroundColor)
.frame(width: 120, height: 120)
if achievement.isEarned {
Circle()
.stroke(Theme.warmOrange, lineWidth: 4)
.frame(width: 130, height: 130)
}
Image(systemName: achievement.definition.iconName)
.font(.system(size: 56))
.foregroundStyle(badgeIconColor)
if !achievement.isEarned {
Circle()
.fill(.black.opacity(0.3))
.frame(width: 120, height: 120)
Image(systemName: "lock.fill")
.font(.system(size: 24))
.foregroundStyle(.white)
}
}
// Title and description
VStack(spacing: Theme.Spacing.sm) {
Text(achievement.definition.name)
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(achievement.definition.description)
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
// Category badge
HStack(spacing: 4) {
Image(systemName: achievement.definition.category.iconName)
Text(achievement.definition.category.displayName)
}
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(categoryColor)
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, Theme.Spacing.xs)
.background(categoryColor.opacity(0.15))
.clipShape(Capsule())
}
// Status section
if achievement.isEarned {
if let earnedAt = achievement.earnedAt {
VStack(spacing: 4) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 24))
.foregroundStyle(.green)
Text("Earned on \(earnedAt.formatted(date: .long, time: .omitted))")
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
}
}
} else {
// Progress section
VStack(spacing: Theme.Spacing.sm) {
Text("Progress")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textMuted(colorScheme))
ProgressView(value: achievement.progressPercentage)
.progressViewStyle(LargeProgressStyle())
.frame(width: 200)
Text("\(achievement.currentProgress) / \(achievement.totalRequired)")
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold))
.foregroundStyle(Theme.textPrimary(colorScheme))
}
}
// Sport badge if applicable
if let sport = achievement.definition.sport {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: sport.iconName)
Text(sport.displayName)
}
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(sport.themeColor)
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.background(sport.themeColor.opacity(0.15))
.clipShape(Capsule())
}
Spacer()
}
.padding(Theme.Spacing.lg)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
}
}
}
}
private var badgeBackgroundColor: Color {
if achievement.isEarned {
return categoryColor.opacity(0.2)
}
return Theme.cardBackgroundElevated(colorScheme)
}
private var badgeIconColor: Color {
if achievement.isEarned {
return categoryColor
}
return Theme.textMuted(colorScheme)
}
private var categoryColor: Color {
switch achievement.definition.category {
case .count:
return Theme.warmOrange
case .division:
return Theme.routeGold
case .conference:
return Theme.routeAmber
case .league:
return Color(hex: "FFD700")
case .journey:
return Color(hex: "9B59B6")
case .special:
return Color(hex: "E74C3C")
}
}
}
// MARK: - Large Progress Style
struct LargeProgressStyle: ProgressViewStyle {
@Environment(\.colorScheme) private var colorScheme
func makeBody(configuration: Configuration) -> some View {
GeometryReader { geometry in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 4)
.fill(Theme.cardBackgroundElevated(colorScheme))
.frame(height: 8)
RoundedRectangle(cornerRadius: 4)
.fill(Theme.warmOrange)
.frame(width: geometry.size.width * CGFloat(configuration.fractionCompleted ?? 0), height: 8)
}
}
.frame(height: 8)
}
}
// MARK: - Category Extensions
extension AchievementCategory {
var iconName: String {
switch self {
case .count: return "number.circle"
case .division: return "map"
case .conference: return "building.2"
case .league: return "crown"
case .journey: return "car.fill"
case .special: return "star.circle"
}
}
}
// MARK: - Preview
#Preview {
NavigationStack {
AchievementsListView()
}
.modelContainer(for: StadiumVisit.self, inMemory: true)
}

View File

@@ -0,0 +1,341 @@
//
// GameMatchConfirmationView.swift
// SportsTime
//
// View for confirming/selecting the correct game match from photo import.
//
import SwiftUI
// MARK: - Game Match Confirmation View
struct GameMatchConfirmationView: View {
let candidate: PhotoImportCandidate
let onConfirm: (GameMatchCandidate) -> Void
let onSkip: () -> Void
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var selectedMatch: GameMatchCandidate?
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Photo info header
photoInfoHeader
.staggeredAnimation(index: 0)
// Stadium info
if let stadium = candidate.bestStadiumMatch {
stadiumCard(stadium)
.staggeredAnimation(index: 1)
}
// Match options
matchOptionsSection
.staggeredAnimation(index: 2)
// Action buttons
actionButtons
.staggeredAnimation(index: 3)
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.navigationTitle("Confirm Game")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
.onAppear {
// Pre-select if single match
if case .singleMatch(let match) = candidate.matchResult {
selectedMatch = match
}
}
}
// MARK: - Photo Info Header
private var photoInfoHeader: some View {
VStack(spacing: Theme.Spacing.md) {
// Icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 80, height: 80)
Image(systemName: "photo.fill")
.font(.system(size: 36))
.foregroundStyle(Theme.warmOrange)
}
VStack(spacing: Theme.Spacing.xs) {
if let date = candidate.metadata.captureDate {
Label(formatDate(date), systemImage: "calendar")
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
}
if candidate.metadata.hasValidLocation {
Label("Location data available", systemImage: "location.fill")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
} else {
Label("No location data", systemImage: "location.slash")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(.red)
}
}
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
}
// MARK: - Stadium Card
private func stadiumCard(_ match: StadiumMatch) -> some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
HStack {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(Theme.warmOrange)
Text("Nearest Stadium")
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
}
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(match.stadium.name)
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(match.stadium.fullAddress)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
// Distance badge
VStack(spacing: 2) {
Text(match.formattedDistance)
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(confidenceColor(match.confidence))
Text(match.confidence.description)
.font(.system(size: 10))
.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)
}
}
// MARK: - Match Options Section
private var matchOptionsSection: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
HStack {
Image(systemName: "sportscourt.fill")
.foregroundStyle(Theme.warmOrange)
Text(matchOptionsTitle)
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
}
matchOptionsContent
}
.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)
}
}
private var matchOptionsTitle: String {
switch candidate.matchResult {
case .singleMatch:
return "Matched Game"
case .multipleMatches(let matches):
return "Select Game (\(matches.count) options)"
case .noMatches:
return "No Games Found"
}
}
@ViewBuilder
private var matchOptionsContent: some View {
switch candidate.matchResult {
case .singleMatch(let match):
gameMatchRow(match, isSelected: true)
case .multipleMatches(let matches):
VStack(spacing: Theme.Spacing.sm) {
ForEach(matches) { match in
Button {
selectedMatch = match
} label: {
gameMatchRow(match, isSelected: selectedMatch?.id == match.id)
}
}
}
case .noMatches(let reason):
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.red)
Text(reason.description)
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.padding(Theme.Spacing.md)
}
}
private func gameMatchRow(_ match: GameMatchCandidate, isSelected: Bool) -> some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(match.matchupDescription)
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Image(systemName: match.game.sport.iconName)
.font(.caption)
.foregroundStyle(match.game.sport.themeColor)
}
Text(match.gameDateTime)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
// Confidence
HStack(spacing: 4) {
Circle()
.fill(combinedConfidenceColor(match.confidence.combined))
.frame(width: 8, height: 8)
Text(match.confidence.combined.description)
.font(.system(size: 10))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
Spacer()
// Selection indicator
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isSelected ? .green : Theme.textMuted(colorScheme))
}
.padding(Theme.Spacing.md)
.background(isSelected ? Theme.cardBackgroundElevated(colorScheme) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
.overlay {
if isSelected {
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(.green.opacity(0.5), lineWidth: 2)
}
}
}
// MARK: - Action Buttons
private var actionButtons: some View {
VStack(spacing: Theme.Spacing.sm) {
// Confirm button
Button {
if let match = selectedMatch {
onConfirm(match)
dismiss()
}
} label: {
HStack {
Image(systemName: "checkmark.circle.fill")
Text("Confirm & Import")
}
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(selectedMatch != nil ? .green : Theme.textMuted(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(selectedMatch == nil)
// Skip button
Button {
onSkip()
dismiss()
} label: {
Text("Skip This Photo")
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
}
// MARK: - Helpers
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .long
formatter.timeStyle = .short
return formatter.string(from: date)
}
private func confidenceColor(_ confidence: MatchConfidence) -> Color {
switch confidence {
case .high: return .green
case .medium: return Theme.warmOrange
case .low: return .red
case .none: return Theme.textMuted(colorScheme)
}
}
private func combinedConfidenceColor(_ confidence: CombinedConfidence) -> Color {
switch confidence {
case .autoSelect: return .green
case .userConfirm: return Theme.warmOrange
case .manualOnly: return .red
}
}
}
// MARK: - Preview
#Preview {
let metadata = PhotoMetadata(
captureDate: Date(),
coordinates: nil
)
let candidate = PhotoImportCandidate(
metadata: metadata,
matchResult: .noMatches(.metadataMissing(.noLocation)),
stadiumMatches: []
)
GameMatchConfirmationView(
candidate: candidate,
onConfirm: { _ in },
onSkip: {}
)
}

View File

@@ -0,0 +1,548 @@
//
// PhotoImportView.swift
// SportsTime
//
// View for importing stadium visits from photos using GPS/date metadata.
//
import SwiftUI
import SwiftData
import PhotosUI
import Photos
// MARK: - Photo Import View
struct PhotoImportView: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var viewModel = PhotoImportViewModel()
@State private var selectedPhotos: [PhotosPickerItem] = []
@State private var showingPermissionAlert = false
var body: some View {
NavigationStack {
VStack(spacing: 0) {
if viewModel.isProcessing {
processingView
} else if viewModel.processedPhotos.isEmpty {
emptyStateView
} else {
resultsView
}
}
.themedBackground()
.navigationTitle("Import from Photos")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
if !viewModel.processedPhotos.isEmpty {
ToolbarItem(placement: .confirmationAction) {
Button("Import") {
importSelectedVisits()
}
.fontWeight(.semibold)
.disabled(!viewModel.hasConfirmedImports)
}
}
}
.photosPicker(
isPresented: $viewModel.showingPicker,
selection: $selectedPhotos,
maxSelectionCount: 20,
matching: .images,
photoLibrary: .shared()
)
.onChange(of: selectedPhotos) { _, newValue in
Task {
await viewModel.processSelectedPhotos(newValue)
}
}
.alert("Photo Library Access", isPresented: $showingPermissionAlert) {
Button("Open Settings") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
Button("Cancel", role: .cancel) {}
} message: {
Text("SportsTime needs access to your photos to import stadium visits. Please enable access in Settings.")
}
}
}
// MARK: - Empty State
private var emptyStateView: some View {
VStack(spacing: Theme.Spacing.lg) {
Spacer()
// Icon
ZStack {
Circle()
.fill(Theme.warmOrange.opacity(0.15))
.frame(width: 120, height: 120)
Image(systemName: "photo.on.rectangle.angled")
.font(.system(size: 50))
.foregroundStyle(Theme.warmOrange)
}
VStack(spacing: Theme.Spacing.sm) {
Text("Import from Photos")
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Select photos taken at stadiums to automatically log your visits. We'll use GPS and date data to match them to games.")
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
.padding(.horizontal, Theme.Spacing.xl)
}
// Select Photos Button
Button {
checkPermissionsAndShowPicker()
} label: {
HStack {
Image(systemName: "photo.stack")
Text("Select Photos")
}
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(.white)
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.padding(.horizontal, Theme.Spacing.xl)
// Info card
infoCard
Spacer()
}
.padding(Theme.Spacing.md)
}
private var infoCard: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "info.circle.fill")
.foregroundStyle(Theme.warmOrange)
Text("How it works")
.font(.system(size: Theme.FontSize.body, weight: .semibold))
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
InfoRow(icon: "location.fill", text: "We read GPS location from your photos")
InfoRow(icon: "calendar", text: "We match the date to scheduled games")
InfoRow(icon: "checkmark.circle", text: "High confidence matches are auto-selected")
InfoRow(icon: "hand.tap", text: "You confirm or edit the rest")
}
}
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.padding(.horizontal, Theme.Spacing.lg)
}
// MARK: - Processing View
private var processingView: some View {
VStack(spacing: Theme.Spacing.lg) {
Spacer()
ThemedSpinner(size: 50, lineWidth: 4)
Text("Processing photos...")
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textSecondary(colorScheme))
Text("\(viewModel.processedCount) of \(viewModel.totalCount) photos")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textMuted(colorScheme))
Spacer()
}
}
// MARK: - Results View
private var resultsView: some View {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Summary header
summaryHeader
// Categorized results
if !viewModel.categorized.autoProcessable.isEmpty {
resultSection(
title: "Auto-Matched",
subtitle: "High confidence matches",
icon: "checkmark.circle.fill",
color: .green,
candidates: viewModel.categorized.autoProcessable
)
}
if !viewModel.categorized.needsConfirmation.isEmpty {
resultSection(
title: "Needs Confirmation",
subtitle: "Please verify these matches",
icon: "questionmark.circle.fill",
color: Theme.warmOrange,
candidates: viewModel.categorized.needsConfirmation
)
}
if !viewModel.categorized.needsManualEntry.isEmpty {
resultSection(
title: "Manual Entry Required",
subtitle: "Could not auto-match these photos",
icon: "exclamationmark.triangle.fill",
color: .red,
candidates: viewModel.categorized.needsManualEntry
)
}
// Add more photos button
Button {
viewModel.showingPicker = true
} label: {
HStack {
Image(systemName: "plus.circle")
Text("Add More Photos")
}
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.warmOrange)
}
.padding(.top, Theme.Spacing.md)
}
.padding(Theme.Spacing.md)
}
}
private var summaryHeader: some View {
HStack(spacing: Theme.Spacing.md) {
summaryBadge(
count: viewModel.categorized.autoProcessable.count,
label: "Auto",
color: .green
)
summaryBadge(
count: viewModel.categorized.needsConfirmation.count,
label: "Confirm",
color: Theme.warmOrange
)
summaryBadge(
count: viewModel.categorized.needsManualEntry.count,
label: "Manual",
color: .red
)
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
private func summaryBadge(count: Int, label: String, color: Color) -> some View {
VStack(spacing: 4) {
Text("\(count)")
.font(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.foregroundStyle(color)
Text(label)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity)
}
private func resultSection(
title: String,
subtitle: String,
icon: String,
color: Color,
candidates: [PhotoImportCandidate]
) -> some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
// Section header
HStack {
Image(systemName: icon)
.foregroundStyle(color)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(subtitle)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
}
// Candidate cards
ForEach(candidates) { candidate in
PhotoImportCandidateCard(
candidate: candidate,
isConfirmed: viewModel.confirmedImports.contains(candidate.id),
onToggleConfirm: {
viewModel.toggleConfirmation(for: candidate.id)
},
onSelectMatch: { match in
viewModel.selectMatch(match, for: candidate.id)
}
)
}
}
.padding(Theme.Spacing.md)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
}
// MARK: - Actions
private func checkPermissionsAndShowPicker() {
Task {
let status = await PhotoMetadataExtractor.shared.requestPhotoLibraryAccess()
await MainActor.run {
switch status {
case .authorized, .limited:
viewModel.showingPicker = true
case .denied, .restricted:
showingPermissionAlert = true
default:
break
}
}
}
}
private func importSelectedVisits() {
Task {
await viewModel.createVisits(modelContext: modelContext)
dismiss()
}
}
}
// MARK: - Photo Import Candidate Card
struct PhotoImportCandidateCard: View {
let candidate: PhotoImportCandidate
let isConfirmed: Bool
let onToggleConfirm: () -> Void
let onSelectMatch: (GameMatchCandidate) -> Void
@Environment(\.colorScheme) private var colorScheme
@State private var showingMatchPicker = false
var body: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
// Photo date/location info
HStack {
if let date = candidate.metadata.captureDate {
Label(formatDate(date), systemImage: "calendar")
}
if let stadium = candidate.bestStadiumMatch {
Label(stadium.stadium.name, systemImage: "mappin")
}
Spacer()
// Confirm toggle
Button {
onToggleConfirm()
} label: {
Image(systemName: isConfirmed ? "checkmark.circle.fill" : "circle")
.font(.title2)
.foregroundStyle(isConfirmed ? .green : Theme.textMuted(colorScheme))
}
}
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
// Match result
matchResultView
}
.padding(Theme.Spacing.sm)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
.sheet(isPresented: $showingMatchPicker) {
if case .multipleMatches(let matches) = candidate.matchResult {
GameMatchPickerSheet(
matches: matches,
onSelect: { match in
onSelectMatch(match)
showingMatchPicker = false
}
)
}
}
}
@ViewBuilder
private var matchResultView: some View {
switch candidate.matchResult {
case .singleMatch(let match):
matchRow(match)
case .multipleMatches(let matches):
Button {
showingMatchPicker = true
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("\(matches.count) possible games")
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Tap to select the correct game")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.warmOrange)
}
Spacer()
Image(systemName: "chevron.right")
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
case .noMatches(let reason):
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundStyle(.red)
Text(reason.description)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
}
private func matchRow(_ match: GameMatchCandidate) -> some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(match.matchupDescription)
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Image(systemName: match.game.sport.iconName)
.foregroundStyle(match.game.sport.themeColor)
}
Text("\(match.stadium.name)\(match.gameDateTime)")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
// Confidence badge
confidenceBadge(match.confidence.combined)
}
}
private func confidenceBadge(_ confidence: CombinedConfidence) -> some View {
let (text, color): (String, Color) = {
switch confidence {
case .autoSelect:
return ("High confidence", .green)
case .userConfirm:
return ("Needs confirmation", Theme.warmOrange)
case .manualOnly:
return ("Low confidence", .red)
}
}()
return Text(text)
.font(.system(size: 10, weight: .medium))
.foregroundStyle(color)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.15))
.clipShape(Capsule())
}
private func formatDate(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: date)
}
}
// MARK: - Game Match Picker Sheet
struct GameMatchPickerSheet: View {
let matches: [GameMatchCandidate]
let onSelect: (GameMatchCandidate) -> Void
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List(matches) { match in
Button {
onSelect(match)
} label: {
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(match.fullMatchupDescription)
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Image(systemName: match.game.sport.iconName)
.foregroundStyle(match.game.sport.themeColor)
}
Text("\(match.stadium.name)\(match.gameDateTime)")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.padding(.vertical, 4)
}
}
.navigationTitle("Select Game")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
}
// MARK: - Info Row
private struct InfoRow: View {
let icon: String
let text: String
var body: some View {
HStack(spacing: 8) {
Image(systemName: icon)
.frame(width: 16)
Text(text)
}
}
}
// MARK: - Preview
#Preview {
PhotoImportView()
.modelContainer(for: StadiumVisit.self, inMemory: true)
}

View File

@@ -0,0 +1,186 @@
//
// ProgressMapView.swift
// SportsTime
//
// Interactive map showing stadium visit progress with custom annotations.
//
import SwiftUI
import MapKit
// MARK: - Progress Map View
struct ProgressMapView: View {
let stadiums: [Stadium]
let visitStatus: [UUID: StadiumVisitStatus]
@Binding var selectedStadium: Stadium?
@State private var mapRegion = MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795), // US center
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50)
)
var body: some View {
Map(coordinateRegion: $mapRegion, annotationItems: stadiums) { stadium in
MapAnnotation(coordinate: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
)) {
StadiumMapPin(
stadium: stadium,
isVisited: isVisited(stadium),
isSelected: selectedStadium?.id == stadium.id,
onTap: {
withAnimation(.spring(response: 0.3)) {
if selectedStadium?.id == stadium.id {
selectedStadium = nil
} else {
selectedStadium = stadium
}
}
}
)
}
}
.mapStyle(.standard(elevation: .realistic))
.clipShape(RoundedRectangle(cornerRadius: 16))
}
private func isVisited(_ stadium: Stadium) -> Bool {
if case .visited = visitStatus[stadium.id] {
return true
}
return false
}
}
// MARK: - Stadium Map Pin
struct StadiumMapPin: View {
let stadium: Stadium
let isVisited: Bool
let isSelected: Bool
let onTap: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: onTap) {
VStack(spacing: 2) {
ZStack {
// Pin background
Circle()
.fill(pinColor)
.frame(width: pinSize, height: pinSize)
.shadow(color: .black.opacity(0.2), radius: 2, y: 1)
// Icon
Image(systemName: isVisited ? "checkmark" : "sportscourt")
.font(.system(size: iconSize, weight: .bold))
.foregroundStyle(.white)
}
// Pin pointer
Triangle()
.fill(pinColor)
.frame(width: 10, height: 6)
.offset(y: -2)
// Stadium name (when selected)
if isSelected {
Text(stadium.name)
.font(.caption2)
.fontWeight(.semibold)
.foregroundStyle(colorScheme == .dark ? .white : .primary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background {
Capsule()
.fill(colorScheme == .dark ? Color(.systemGray5) : .white)
.shadow(color: .black.opacity(0.15), radius: 4, y: 2)
}
.fixedSize()
.transition(.scale.combined(with: .opacity))
}
}
}
.buttonStyle(.plain)
.animation(.spring(response: 0.3), value: isSelected)
}
private var pinColor: Color {
if isVisited {
return .green
} else {
return .orange
}
}
private var pinSize: CGFloat {
isSelected ? 36 : 28
}
private var iconSize: CGFloat {
isSelected ? 16 : 12
}
}
// MARK: - Triangle Shape
struct Triangle: Shape {
func path(in rect: CGRect) -> Path {
var path = Path()
path.move(to: CGPoint(x: rect.midX, y: rect.maxY))
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY))
path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY))
path.closeSubpath()
return path
}
}
// MARK: - Map Region Extension
extension ProgressMapView {
/// Calculate region to fit all stadiums
static func region(for stadiums: [Stadium]) -> MKCoordinateRegion {
guard !stadiums.isEmpty else {
// Default to US center
return MKCoordinateRegion(
center: CLLocationCoordinate2D(latitude: 39.8283, longitude: -98.5795),
span: MKCoordinateSpan(latitudeDelta: 50, longitudeDelta: 50)
)
}
let latitudes = stadiums.map { $0.latitude }
let longitudes = stadiums.map { $0.longitude }
let minLat = latitudes.min()!
let maxLat = latitudes.max()!
let minLon = longitudes.min()!
let maxLon = longitudes.max()!
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
let span = MKCoordinateSpan(
latitudeDelta: (maxLat - minLat) * 1.3 + 2, // Add padding
longitudeDelta: (maxLon - minLon) * 1.3 + 2
)
return MKCoordinateRegion(center: center, span: span)
}
}
// MARK: - Preview
#Preview {
ProgressMapView(
stadiums: [],
visitStatus: [:],
selectedStadium: .constant(nil)
)
.frame(height: 300)
.padding()
}

View File

@@ -0,0 +1,685 @@
//
// 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 {
HStack(spacing: Theme.Spacing.sm) {
ForEach(Sport.supported) { sport in
LeagueSelectorButton(
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(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("/\(progress.totalStadiums)")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text(viewModel.selectedSport.displayName)
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Stadium Quest")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
if progress.isComplete {
HStack(spacing: 4) {
Image(systemName: "checkmark.seal.fill")
Text("Complete!")
}
.font(.system(size: Theme.FontSize.caption, weight: .semibold))
.foregroundStyle(Theme.warmOrange)
} else {
Text("\(progress.totalStadiums - progress.visitedStadiums) stadiums remaining")
.font(.system(size: Theme.FontSize.caption))
.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(.system(size: Theme.FontSize.body, weight: .semibold))
.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(.system(size: Theme.FontSize.body, weight: .semibold))
.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(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
NavigationLink {
AchievementsListView()
} label: {
HStack(spacing: 4) {
Text("View All")
Image(systemName: "chevron.right")
}
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.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(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text("Earn badges for stadium visits")
.font(.system(size: Theme.FontSize.caption))
.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(.system(size: Theme.FontSize.sectionTitle, weight: .bold, design: .rounded))
.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 LeagueSelectorButton: View {
let sport: Sport
let isSelected: Bool
let progress: Double
let action: () -> Void
@Environment(\.colorScheme) private var colorScheme
var body: some View {
Button(action: action) {
VStack(spacing: Theme.Spacing.xs) {
ZStack {
// Background circle with progress
Circle()
.stroke(sport.themeColor.opacity(0.2), lineWidth: 3)
.frame(width: 50, height: 50)
Circle()
.trim(from: 0, to: progress)
.stroke(sport.themeColor, style: StrokeStyle(lineWidth: 3, lineCap: .round))
.frame(width: 50, height: 50)
.rotationEffect(.degrees(-90))
// Sport icon
Image(systemName: sport.iconName)
.font(.title2)
.foregroundStyle(isSelected ? sport.themeColor : Theme.textMuted(colorScheme))
}
Text(sport.rawValue)
.font(.system(size: Theme.FontSize.micro, weight: isSelected ? .bold : .medium))
.foregroundStyle(isSelected ? Theme.textPrimary(colorScheme) : Theme.textMuted(colorScheme))
}
.frame(maxWidth: .infinity)
.padding(.vertical, Theme.Spacing.sm)
.background(isSelected ? Theme.cardBackground(colorScheme) : Color.clear)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
.overlay {
if isSelected {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.stroke(sport.themeColor, lineWidth: 2)
}
}
}
.buttonStyle(.plain)
}
}
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(.system(size: 12))
Text(value)
.font(.system(size: Theme.FontSize.body, weight: .bold))
}
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(label)
.font(.system(size: Theme.FontSize.micro))
.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(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
.lineLimit(1)
Text(stadium.city)
.font(.system(size: Theme.FontSize.micro))
.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(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
HStack(spacing: Theme.Spacing.sm) {
Text(visit.shortDateDescription)
if let matchup = visit.matchup {
Text("")
Text(matchup)
}
}
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
if visit.photoCount > 0 {
HStack(spacing: 4) {
Image(systemName: "photo")
Text("\(visit.photoCount)")
}
.font(.system(size: Theme.FontSize.micro))
.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(.system(size: 36))
.foregroundStyle(visitStatus.isVisited ? .green : sport.themeColor)
}
Text(stadium.name)
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
.multilineTextAlignment(.center)
Text(stadium.fullAddress)
.font(.system(size: Theme.FontSize.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(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(.green)
}
}
// Visit history if visited
if case .visited(let visits) = visitStatus {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Visit History")
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
ForEach(visits.sorted(by: { $0.visitDate > $1.visitDate })) { visit in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(visit.shortDateDescription)
.font(.system(size: Theme.FontSize.caption, weight: .medium))
if let matchup = visit.matchup {
Text(matchup)
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
}
Spacer()
Text(visit.visitType.displayName)
.font(.system(size: Theme.FontSize.micro))
.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)
}

View File

@@ -0,0 +1,357 @@
//
// StadiumVisitSheet.swift
// SportsTime
//
// Sheet for manually logging a stadium visit.
//
import SwiftUI
import SwiftData
struct StadiumVisitSheet: View {
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
// Optional pre-selected values
var initialStadium: Stadium?
var initialSport: Sport?
var onSave: ((StadiumVisit) -> Void)?
// Form state
@State private var selectedSport: Sport
@State private var selectedStadium: Stadium?
@State private var visitDate: Date = Date()
@State private var visitType: VisitType = .game
@State private var homeTeamName: String = ""
@State private var awayTeamName: String = ""
@State private var homeScore: String = ""
@State private var awayScore: String = ""
@State private var seatLocation: String = ""
@State private var notes: String = ""
// UI state
@State private var showStadiumPicker = false
@State private var isSaving = false
@State private var errorMessage: String?
// Data
private let dataProvider = AppDataProvider.shared
init(
initialStadium: Stadium? = nil,
initialSport: Sport? = nil,
onSave: ((StadiumVisit) -> Void)? = nil
) {
self.initialStadium = initialStadium
self.initialSport = initialSport
self.onSave = onSave
_selectedSport = State(initialValue: initialSport ?? .mlb)
_selectedStadium = State(initialValue: initialStadium)
}
var body: some View {
NavigationStack {
Form {
// Sport & Stadium Section
Section {
// Sport Picker
Picker("Sport", selection: $selectedSport) {
ForEach(Sport.supported) { sport in
HStack {
Image(systemName: sport.iconName)
Text(sport.displayName)
}
.tag(sport)
}
}
// Stadium Selection
Button {
showStadiumPicker = true
} label: {
HStack {
Text("Stadium")
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
if let stadium = selectedStadium {
Text(stadium.name)
.foregroundStyle(Theme.textSecondary(colorScheme))
} else {
Text("Select Stadium")
.foregroundStyle(Theme.textMuted(colorScheme))
}
Image(systemName: "chevron.right")
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
} header: {
Text("Location")
}
// Visit Details Section
Section {
DatePicker("Date", selection: $visitDate, displayedComponents: .date)
Picker("Visit Type", selection: $visitType) {
ForEach(VisitType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
} header: {
Text("Visit Details")
}
// Game Info Section (only for game visits)
if visitType == .game {
Section {
HStack {
Text("Away Team")
Spacer()
TextField("Team Name", text: $awayTeamName)
.multilineTextAlignment(.trailing)
}
HStack {
Text("Home Team")
Spacer()
TextField("Team Name", text: $homeTeamName)
.multilineTextAlignment(.trailing)
}
HStack {
Text("Final Score")
Spacer()
TextField("Away", text: $awayScore)
.keyboardType(.numberPad)
.frame(width: 50)
.multilineTextAlignment(.center)
Text("-")
.foregroundStyle(Theme.textMuted(colorScheme))
TextField("Home", text: $homeScore)
.keyboardType(.numberPad)
.frame(width: 50)
.multilineTextAlignment(.center)
}
} header: {
Text("Game Info")
} footer: {
Text("Leave blank if you don't remember the score")
}
}
// Optional Details Section
Section {
HStack {
Text("Seat Location")
Spacer()
TextField("e.g., Section 120", text: $seatLocation)
.multilineTextAlignment(.trailing)
}
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Notes")
TextEditor(text: $notes)
.frame(minHeight: 80)
.scrollContentBackground(.hidden)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
}
} header: {
Text("Additional Info")
}
// Error Message
if let error = errorMessage {
Section {
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
Text(error)
.foregroundStyle(.red)
}
}
}
}
.scrollDismissesKeyboard(.interactively)
.navigationTitle("Log Visit")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
saveVisit()
}
.disabled(!canSave || isSaving)
.fontWeight(.semibold)
}
}
.sheet(isPresented: $showStadiumPicker) {
StadiumPickerSheet(
sport: selectedSport,
selectedStadium: $selectedStadium
)
}
.onChange(of: selectedSport) { _, _ in
// Clear stadium selection when sport changes
if let stadium = selectedStadium {
// Check if stadium belongs to new sport
let sportTeams = dataProvider.teams.filter { $0.sport == selectedSport }
if !sportTeams.contains(where: { $0.stadiumId == stadium.id }) {
selectedStadium = nil
}
}
}
}
}
// MARK: - Computed Properties
private var canSave: Bool {
selectedStadium != nil
}
private var finalScoreString: String? {
guard let away = Int(awayScore), let home = Int(homeScore) else {
return nil
}
return "\(away)-\(home)"
}
// MARK: - Actions
private func saveVisit() {
guard let stadium = selectedStadium else {
errorMessage = "Please select a stadium"
return
}
isSaving = true
errorMessage = nil
// Create the visit
let visit = StadiumVisit(
canonicalStadiumId: stadium.id.uuidString, // Simplified - in production use StadiumIdentityService
stadiumUUID: stadium.id,
stadiumNameAtVisit: stadium.name,
visitDate: visitDate,
sport: selectedSport,
visitType: visitType,
homeTeamName: homeTeamName.isEmpty ? nil : homeTeamName,
awayTeamName: awayTeamName.isEmpty ? nil : awayTeamName,
finalScore: finalScoreString,
scoreSource: finalScoreString != nil ? .user : nil,
dataSource: .fullyManual,
seatLocation: seatLocation.isEmpty ? nil : seatLocation,
notes: notes.isEmpty ? nil : notes,
source: .manual
)
// Save to SwiftData
modelContext.insert(visit)
do {
try modelContext.save()
onSave?(visit)
dismiss()
} catch {
errorMessage = "Failed to save visit: \(error.localizedDescription)"
isSaving = false
}
}
}
// MARK: - Stadium Picker Sheet
struct StadiumPickerSheet: View {
let sport: Sport
@Binding var selectedStadium: Stadium?
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var searchText = ""
private let dataProvider = AppDataProvider.shared
private var stadiums: [Stadium] {
let sportTeams = dataProvider.teams.filter { $0.sport == sport }
let stadiumIds = Set(sportTeams.map { $0.stadiumId })
return dataProvider.stadiums.filter { stadiumIds.contains($0.id) }
}
private var filteredStadiums: [Stadium] {
if searchText.isEmpty {
return stadiums.sorted { $0.name < $1.name }
}
return stadiums.filter {
$0.name.localizedCaseInsensitiveContains(searchText) ||
$0.city.localizedCaseInsensitiveContains(searchText)
}.sorted { $0.name < $1.name }
}
var body: some View {
NavigationStack {
Group {
if stadiums.isEmpty {
ContentUnavailableView(
"No Stadiums",
systemImage: "building.2",
description: Text("No stadiums found for \(sport.displayName)")
)
} else if filteredStadiums.isEmpty {
ContentUnavailableView.search(text: searchText)
} else {
List(filteredStadiums) { stadium in
Button {
selectedStadium = stadium
dismiss()
} label: {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text(stadium.name)
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Text(stadium.fullAddress)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
if selectedStadium?.id == stadium.id {
Image(systemName: "checkmark")
.foregroundStyle(Theme.warmOrange)
}
}
}
}
.scrollDismissesKeyboard(.interactively)
}
}
.searchable(text: $searchText, prompt: "Search stadiums")
.navigationTitle("Select Stadium")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
}
}
}
}
// MARK: - Preview
#Preview {
StadiumVisitSheet()
.modelContainer(for: StadiumVisit.self, inMemory: true)
}

View File

@@ -0,0 +1,538 @@
//
// VisitDetailView.swift
// SportsTime
//
// View for displaying and editing a stadium visit's details.
//
import SwiftUI
import SwiftData
struct VisitDetailView: View {
@Bindable var visit: StadiumVisit
let stadium: Stadium
@Environment(\.modelContext) private var modelContext
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var isEditing = false
@State private var showDeleteConfirmation = false
// Edit state
@State private var editVisitDate: Date
@State private var editVisitType: VisitType
@State private var editHomeTeamName: String
@State private var editAwayTeamName: String
@State private var editHomeScore: String
@State private var editAwayScore: String
@State private var editSeatLocation: String
@State private var editNotes: String
init(visit: StadiumVisit, stadium: Stadium) {
self.visit = visit
self.stadium = stadium
// Initialize edit state from visit
_editVisitDate = State(initialValue: visit.visitDate)
_editVisitType = State(initialValue: visit.visitType)
_editHomeTeamName = State(initialValue: visit.homeTeamName ?? "")
_editAwayTeamName = State(initialValue: visit.awayTeamName ?? "")
// Parse score if available
if let score = visit.finalScore {
let parts = score.split(separator: "-")
if parts.count == 2 {
_editAwayScore = State(initialValue: String(parts[0]))
_editHomeScore = State(initialValue: String(parts[1]))
} else {
_editAwayScore = State(initialValue: "")
_editHomeScore = State(initialValue: "")
}
} else {
_editAwayScore = State(initialValue: "")
_editHomeScore = State(initialValue: "")
}
_editSeatLocation = State(initialValue: visit.seatLocation ?? "")
_editNotes = State(initialValue: visit.notes ?? "")
}
var body: some View {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Header
visitHeader
.staggeredAnimation(index: 0)
// Game info (if applicable)
if visit.visitType == .game {
gameInfoCard
.staggeredAnimation(index: 1)
}
// Visit details
detailsCard
.staggeredAnimation(index: 2)
// Notes
if !isEditing && (visit.notes?.isEmpty == false) {
notesCard
.staggeredAnimation(index: 3)
}
// Edit form (when editing)
if isEditing {
editForm
.staggeredAnimation(index: 4)
}
// Delete button
if !isEditing {
deleteButton
.staggeredAnimation(index: 5)
}
}
.padding(Theme.Spacing.md)
}
.themedBackground()
.navigationTitle(isEditing ? "Edit Visit" : "Visit Details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
if isEditing {
Button("Save") {
saveChanges()
}
.fontWeight(.semibold)
} else {
Button("Edit") {
withAnimation {
isEditing = true
}
}
}
}
if isEditing {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
cancelEditing()
}
}
}
}
.confirmationDialog(
"Delete Visit",
isPresented: $showDeleteConfirmation,
titleVisibility: .visible
) {
Button("Delete Visit", role: .destructive) {
deleteVisit()
}
Button("Cancel", role: .cancel) {}
} message: {
Text("Are you sure you want to delete this visit? This action cannot be undone.")
}
}
// MARK: - Header
private var visitHeader: some View {
VStack(spacing: Theme.Spacing.md) {
// Sport icon
ZStack {
Circle()
.fill(sportColor.opacity(0.15))
.frame(width: 80, height: 80)
Image(systemName: visit.sportEnum?.iconName ?? "sportscourt")
.font(.system(size: 36))
.foregroundStyle(sportColor)
}
VStack(spacing: Theme.Spacing.xs) {
Text(stadium.name)
.font(.system(size: Theme.FontSize.cardTitle, weight: .bold, design: .rounded))
.foregroundStyle(Theme.textPrimary(colorScheme))
.multilineTextAlignment(.center)
Text(stadium.fullAddress)
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
// Visit type badge
Text(visit.visitType.displayName)
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(.white)
.padding(.horizontal, Theme.Spacing.sm)
.padding(.vertical, 4)
.background(sportColor)
.clipShape(Capsule())
}
}
.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: - Game Info Card
private var gameInfoCard: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
HStack {
Image(systemName: "sportscourt.fill")
.foregroundStyle(sportColor)
Text("Game Info")
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
}
if let matchup = visit.matchupDescription {
HStack {
Text("Matchup")
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(matchup)
.foregroundStyle(Theme.textPrimary(colorScheme))
.fontWeight(.medium)
}
}
if let score = visit.finalScore {
HStack {
Text("Final Score")
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(score)
.foregroundStyle(Theme.textPrimary(colorScheme))
.fontWeight(.bold)
}
}
}
.font(.system(size: Theme.FontSize.body))
.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: - Details Card
private var detailsCard: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
HStack {
Image(systemName: "info.circle.fill")
.foregroundStyle(Theme.warmOrange)
Text("Details")
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
}
// Date
HStack {
Text("Date")
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(formattedDate)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
// Seat location
if let seat = visit.seatLocation, !seat.isEmpty {
HStack {
Text("Seat")
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(seat)
.foregroundStyle(Theme.textPrimary(colorScheme))
}
}
// Source
HStack {
Text("Source")
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(visit.source.displayName)
.foregroundStyle(Theme.textMuted(colorScheme))
}
// Created date
HStack {
Text("Logged")
.foregroundStyle(Theme.textSecondary(colorScheme))
Spacer()
Text(formattedCreatedDate)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.font(.system(size: Theme.FontSize.body))
.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: - Notes Card
private var notesCard: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
HStack {
Image(systemName: "note.text")
.foregroundStyle(Theme.routeGold)
Text("Notes")
.font(.system(size: Theme.FontSize.body, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
}
Text(visit.notes ?? "")
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity, alignment: .leading)
.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: - Edit Form
private var editForm: some View {
VStack(alignment: .leading, spacing: Theme.Spacing.lg) {
// Date
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Date")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textSecondary(colorScheme))
DatePicker("", selection: $editVisitDate, displayedComponents: .date)
.labelsHidden()
}
// Visit Type
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Visit Type")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textSecondary(colorScheme))
Picker("", selection: $editVisitType) {
ForEach(VisitType.allCases, id: \.self) { type in
Text(type.displayName).tag(type)
}
}
.pickerStyle(.segmented)
}
// Game info (if game type)
if editVisitType == .game {
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
Text("Game Info")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textSecondary(colorScheme))
HStack {
TextField("Away Team", text: $editAwayTeamName)
.textFieldStyle(.roundedBorder)
Text("@")
.foregroundStyle(Theme.textMuted(colorScheme))
TextField("Home Team", text: $editHomeTeamName)
.textFieldStyle(.roundedBorder)
}
HStack {
TextField("Away Score", text: $editAwayScore)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.frame(width: 80)
Text("-")
.foregroundStyle(Theme.textMuted(colorScheme))
TextField("Home Score", text: $editHomeScore)
.textFieldStyle(.roundedBorder)
.keyboardType(.numberPad)
.frame(width: 80)
Spacer()
}
}
}
// Seat location
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Seat Location")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textSecondary(colorScheme))
TextField("e.g., Section 120, Row 5", text: $editSeatLocation)
.textFieldStyle(.roundedBorder)
}
// Notes
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Notes")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textSecondary(colorScheme))
TextEditor(text: $editNotes)
.frame(minHeight: 100)
.scrollContentBackground(.hidden)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.small)
.stroke(Theme.surfaceGlow(colorScheme), lineWidth: 1)
}
}
}
.padding(Theme.Spacing.lg)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.overlay {
RoundedRectangle(cornerRadius: Theme.CornerRadius.large)
.stroke(Theme.warmOrange.opacity(0.5), lineWidth: 2)
}
}
// MARK: - Delete Button
private var deleteButton: some View {
Button(role: .destructive) {
showDeleteConfirmation = true
} label: {
HStack {
Image(systemName: "trash")
Text("Delete Visit")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Color.red.opacity(0.1))
.foregroundStyle(.red)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
}
// MARK: - Computed Properties
private var sportColor: Color {
visit.sportEnum?.themeColor ?? Theme.warmOrange
}
private var formattedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .long
return formatter.string(from: visit.visitDate)
}
private var formattedCreatedDate: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: visit.createdAt)
}
// MARK: - Actions
private func saveChanges() {
visit.visitDate = editVisitDate
visit.visitType = editVisitType
visit.homeTeamName = editHomeTeamName.isEmpty ? nil : editHomeTeamName
visit.awayTeamName = editAwayTeamName.isEmpty ? nil : editAwayTeamName
visit.seatLocation = editSeatLocation.isEmpty ? nil : editSeatLocation
visit.notes = editNotes.isEmpty ? nil : editNotes
// Update score
if let away = Int(editAwayScore), let home = Int(editHomeScore) {
visit.finalScore = "\(away)-\(home)"
visit.scoreSource = .user
} else {
visit.finalScore = nil
visit.scoreSource = nil
}
// Mark as user corrected if it wasn't fully manual
if visit.dataSource != .fullyManual {
visit.dataSource = .userCorrected
}
try? modelContext.save()
withAnimation {
isEditing = false
}
}
private func cancelEditing() {
// Reset to original values
editVisitDate = visit.visitDate
editVisitType = visit.visitType
editHomeTeamName = visit.homeTeamName ?? ""
editAwayTeamName = visit.awayTeamName ?? ""
editSeatLocation = visit.seatLocation ?? ""
editNotes = visit.notes ?? ""
if let score = visit.finalScore {
let parts = score.split(separator: "-")
if parts.count == 2 {
editAwayScore = String(parts[0])
editHomeScore = String(parts[1])
}
} else {
editAwayScore = ""
editHomeScore = ""
}
withAnimation {
isEditing = false
}
}
private func deleteVisit() {
modelContext.delete(visit)
try? modelContext.save()
dismiss()
}
}
// MARK: - Visit Source Display Name
extension VisitSource {
var displayName: String {
switch self {
case .trip: return "From Trip"
case .manual: return "Manual Entry"
case .photoImport: return "Photo Import"
}
}
}
// MARK: - Preview
#Preview {
let stadium = Stadium(
name: "Oracle Park",
city: "San Francisco",
state: "CA",
latitude: 37.7786,
longitude: -122.3893,
capacity: 41915,
sport: .mlb
)
NavigationStack {
Text("Preview placeholder")
}
}