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,647 @@
//
// AchievementDefinitions.swift
// SportsTime
//
// Registry of all achievement types and their requirements.
//
import Foundation
import SwiftUI
// MARK: - Achievement Category
enum AchievementCategory: String, Codable, CaseIterable {
case count // Milestone counts (1, 5, 10, etc.)
case division // Complete a division
case conference // Complete a conference
case league // Complete entire league
case journey // Special journey-based achievements
case special // Special one-off achievements
var displayName: String {
switch self {
case .count: return "Milestones"
case .division: return "Divisions"
case .conference: return "Conferences"
case .league: return "Leagues"
case .journey: return "Journeys"
case .special: return "Special"
}
}
}
// MARK: - Achievement Definition
struct AchievementDefinition: Identifiable, Hashable {
let id: String
let name: String
let description: String
let category: AchievementCategory
let sport: Sport? // nil for cross-sport achievements
let iconName: String
let iconColor: Color
let requirement: AchievementRequirement
let sortOrder: Int
// Optional division/conference reference
let divisionId: String?
let conferenceId: String?
init(
id: String,
name: String,
description: String,
category: AchievementCategory,
sport: Sport? = nil,
iconName: String,
iconColor: Color,
requirement: AchievementRequirement,
sortOrder: Int = 0,
divisionId: String? = nil,
conferenceId: String? = nil
) {
self.id = id
self.name = name
self.description = description
self.category = category
self.sport = sport
self.iconName = iconName
self.iconColor = iconColor
self.requirement = requirement
self.sortOrder = sortOrder
self.divisionId = divisionId
self.conferenceId = conferenceId
}
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
static func == (lhs: AchievementDefinition, rhs: AchievementDefinition) -> Bool {
lhs.id == rhs.id
}
}
// MARK: - Achievement Requirement
enum AchievementRequirement: Hashable {
case visitCount(Int) // Visit N unique stadiums
case visitCountForSport(Int, Sport) // Visit N stadiums for a specific sport
case completeDivision(String) // Complete all stadiums in a division
case completeConference(String) // Complete all stadiums in a conference
case completeLeague(Sport) // Complete all stadiums in a league
case visitsInDays(Int, days: Int) // Visit N stadiums within N days
case multipleLeagues(Int) // Visit stadiums from N different leagues
case firstVisit // First stadium visit ever
case specificStadium(String) // Visit a specific stadium
}
// MARK: - Achievement Registry
enum AchievementRegistry {
// MARK: - All Definitions
/// All achievement definitions sorted by category and sort order
static let all: [AchievementDefinition] = {
var definitions: [AchievementDefinition] = []
definitions.append(contentsOf: countAchievements)
definitions.append(contentsOf: mlbDivisionAchievements)
definitions.append(contentsOf: mlbConferenceAchievements)
definitions.append(contentsOf: nbaDivisionAchievements)
definitions.append(contentsOf: nbaConferenceAchievements)
definitions.append(contentsOf: nhlDivisionAchievements)
definitions.append(contentsOf: nhlConferenceAchievements)
definitions.append(contentsOf: leagueAchievements)
definitions.append(contentsOf: journeyAchievements)
definitions.append(contentsOf: specialAchievements)
return definitions.sorted { $0.sortOrder < $1.sortOrder }
}()
// MARK: - Count Achievements
static let countAchievements: [AchievementDefinition] = [
AchievementDefinition(
id: "first_visit",
name: "First Pitch",
description: "Visit your first stadium",
category: .count,
iconName: "1.circle.fill",
iconColor: .green,
requirement: .firstVisit,
sortOrder: 100
),
AchievementDefinition(
id: "count_5",
name: "Getting Started",
description: "Visit 5 different stadiums",
category: .count,
iconName: "5.circle.fill",
iconColor: .blue,
requirement: .visitCount(5),
sortOrder: 101
),
AchievementDefinition(
id: "count_10",
name: "Double Digits",
description: "Visit 10 different stadiums",
category: .count,
iconName: "10.circle.fill",
iconColor: .orange,
requirement: .visitCount(10),
sortOrder: 102
),
AchievementDefinition(
id: "count_20",
name: "Veteran Fan",
description: "Visit 20 different stadiums",
category: .count,
iconName: "20.circle.fill",
iconColor: .purple,
requirement: .visitCount(20),
sortOrder: 103
),
AchievementDefinition(
id: "count_30",
name: "Stadium Enthusiast",
description: "Visit 30 different stadiums",
category: .count,
iconName: "30.circle.fill",
iconColor: .red,
requirement: .visitCount(30),
sortOrder: 104
),
AchievementDefinition(
id: "count_50",
name: "Road Warrior",
description: "Visit 50 different stadiums",
category: .count,
iconName: "50.circle.fill",
iconColor: .yellow,
requirement: .visitCount(50),
sortOrder: 105
),
AchievementDefinition(
id: "count_75",
name: "Stadium Expert",
description: "Visit 75 different stadiums",
category: .count,
iconName: "75.circle.fill",
iconColor: .mint,
requirement: .visitCount(75),
sortOrder: 106
),
AchievementDefinition(
id: "count_all",
name: "Stadium Master",
description: "Visit all 92 MLB, NBA, and NHL stadiums",
category: .count,
iconName: "star.circle.fill",
iconColor: .yellow,
requirement: .visitCount(92),
sortOrder: 107
)
]
// MARK: - MLB Division Achievements
static let mlbDivisionAchievements: [AchievementDefinition] = [
AchievementDefinition(
id: "mlb_al_east_complete",
name: "AL East Champion",
description: "Visit all AL East stadiums",
category: .division,
sport: .mlb,
iconName: "baseball.fill",
iconColor: .red,
requirement: .completeDivision("mlb_al_east"),
sortOrder: 200,
divisionId: "mlb_al_east"
),
AchievementDefinition(
id: "mlb_al_central_complete",
name: "AL Central Champion",
description: "Visit all AL Central stadiums",
category: .division,
sport: .mlb,
iconName: "baseball.fill",
iconColor: .red,
requirement: .completeDivision("mlb_al_central"),
sortOrder: 201,
divisionId: "mlb_al_central"
),
AchievementDefinition(
id: "mlb_al_west_complete",
name: "AL West Champion",
description: "Visit all AL West stadiums",
category: .division,
sport: .mlb,
iconName: "baseball.fill",
iconColor: .red,
requirement: .completeDivision("mlb_al_west"),
sortOrder: 202,
divisionId: "mlb_al_west"
),
AchievementDefinition(
id: "mlb_nl_east_complete",
name: "NL East Champion",
description: "Visit all NL East stadiums",
category: .division,
sport: .mlb,
iconName: "baseball.fill",
iconColor: .red,
requirement: .completeDivision("mlb_nl_east"),
sortOrder: 203,
divisionId: "mlb_nl_east"
),
AchievementDefinition(
id: "mlb_nl_central_complete",
name: "NL Central Champion",
description: "Visit all NL Central stadiums",
category: .division,
sport: .mlb,
iconName: "baseball.fill",
iconColor: .red,
requirement: .completeDivision("mlb_nl_central"),
sortOrder: 204,
divisionId: "mlb_nl_central"
),
AchievementDefinition(
id: "mlb_nl_west_complete",
name: "NL West Champion",
description: "Visit all NL West stadiums",
category: .division,
sport: .mlb,
iconName: "baseball.fill",
iconColor: .red,
requirement: .completeDivision("mlb_nl_west"),
sortOrder: 205,
divisionId: "mlb_nl_west"
)
]
// MARK: - MLB Conference Achievements
static let mlbConferenceAchievements: [AchievementDefinition] = [
AchievementDefinition(
id: "mlb_al_complete",
name: "American League Complete",
description: "Visit all American League stadiums",
category: .conference,
sport: .mlb,
iconName: "baseball.circle.fill",
iconColor: .red,
requirement: .completeConference("mlb_al"),
sortOrder: 300,
conferenceId: "mlb_al"
),
AchievementDefinition(
id: "mlb_nl_complete",
name: "National League Complete",
description: "Visit all National League stadiums",
category: .conference,
sport: .mlb,
iconName: "baseball.circle.fill",
iconColor: .red,
requirement: .completeConference("mlb_nl"),
sortOrder: 301,
conferenceId: "mlb_nl"
)
]
// MARK: - NBA Division Achievements
static let nbaDivisionAchievements: [AchievementDefinition] = [
AchievementDefinition(
id: "nba_atlantic_complete",
name: "Atlantic Division Champion",
description: "Visit all Atlantic Division arenas",
category: .division,
sport: .nba,
iconName: "basketball.fill",
iconColor: .orange,
requirement: .completeDivision("nba_atlantic"),
sortOrder: 210,
divisionId: "nba_atlantic"
),
AchievementDefinition(
id: "nba_central_complete",
name: "Central Division Champion",
description: "Visit all Central Division arenas",
category: .division,
sport: .nba,
iconName: "basketball.fill",
iconColor: .orange,
requirement: .completeDivision("nba_central"),
sortOrder: 211,
divisionId: "nba_central"
),
AchievementDefinition(
id: "nba_southeast_complete",
name: "Southeast Division Champion",
description: "Visit all Southeast Division arenas",
category: .division,
sport: .nba,
iconName: "basketball.fill",
iconColor: .orange,
requirement: .completeDivision("nba_southeast"),
sortOrder: 212,
divisionId: "nba_southeast"
),
AchievementDefinition(
id: "nba_northwest_complete",
name: "Northwest Division Champion",
description: "Visit all Northwest Division arenas",
category: .division,
sport: .nba,
iconName: "basketball.fill",
iconColor: .orange,
requirement: .completeDivision("nba_northwest"),
sortOrder: 213,
divisionId: "nba_northwest"
),
AchievementDefinition(
id: "nba_pacific_complete",
name: "Pacific Division Champion",
description: "Visit all Pacific Division arenas",
category: .division,
sport: .nba,
iconName: "basketball.fill",
iconColor: .orange,
requirement: .completeDivision("nba_pacific"),
sortOrder: 214,
divisionId: "nba_pacific"
),
AchievementDefinition(
id: "nba_southwest_complete",
name: "Southwest Division Champion",
description: "Visit all Southwest Division arenas",
category: .division,
sport: .nba,
iconName: "basketball.fill",
iconColor: .orange,
requirement: .completeDivision("nba_southwest"),
sortOrder: 215,
divisionId: "nba_southwest"
)
]
// MARK: - NBA Conference Achievements
static let nbaConferenceAchievements: [AchievementDefinition] = [
AchievementDefinition(
id: "nba_eastern_complete",
name: "Eastern Conference Complete",
description: "Visit all Eastern Conference arenas",
category: .conference,
sport: .nba,
iconName: "basketball.circle.fill",
iconColor: .orange,
requirement: .completeConference("nba_eastern"),
sortOrder: 310,
conferenceId: "nba_eastern"
),
AchievementDefinition(
id: "nba_western_complete",
name: "Western Conference Complete",
description: "Visit all Western Conference arenas",
category: .conference,
sport: .nba,
iconName: "basketball.circle.fill",
iconColor: .orange,
requirement: .completeConference("nba_western"),
sortOrder: 311,
conferenceId: "nba_western"
)
]
// MARK: - NHL Division Achievements
static let nhlDivisionAchievements: [AchievementDefinition] = [
AchievementDefinition(
id: "nhl_atlantic_complete",
name: "NHL Atlantic Champion",
description: "Visit all Atlantic Division arenas",
category: .division,
sport: .nhl,
iconName: "hockey.puck.fill",
iconColor: .blue,
requirement: .completeDivision("nhl_atlantic"),
sortOrder: 220,
divisionId: "nhl_atlantic"
),
AchievementDefinition(
id: "nhl_metropolitan_complete",
name: "Metropolitan Champion",
description: "Visit all Metropolitan Division arenas",
category: .division,
sport: .nhl,
iconName: "hockey.puck.fill",
iconColor: .blue,
requirement: .completeDivision("nhl_metropolitan"),
sortOrder: 221,
divisionId: "nhl_metropolitan"
),
AchievementDefinition(
id: "nhl_central_complete",
name: "NHL Central Champion",
description: "Visit all Central Division arenas",
category: .division,
sport: .nhl,
iconName: "hockey.puck.fill",
iconColor: .blue,
requirement: .completeDivision("nhl_central"),
sortOrder: 222,
divisionId: "nhl_central"
),
AchievementDefinition(
id: "nhl_pacific_complete",
name: "NHL Pacific Champion",
description: "Visit all Pacific Division arenas",
category: .division,
sport: .nhl,
iconName: "hockey.puck.fill",
iconColor: .blue,
requirement: .completeDivision("nhl_pacific"),
sortOrder: 223,
divisionId: "nhl_pacific"
)
]
// MARK: - NHL Conference Achievements
static let nhlConferenceAchievements: [AchievementDefinition] = [
AchievementDefinition(
id: "nhl_eastern_complete",
name: "NHL Eastern Conference Complete",
description: "Visit all Eastern Conference arenas",
category: .conference,
sport: .nhl,
iconName: "hockey.puck.circle.fill",
iconColor: .blue,
requirement: .completeConference("nhl_eastern"),
sortOrder: 320,
conferenceId: "nhl_eastern"
),
AchievementDefinition(
id: "nhl_western_complete",
name: "NHL Western Conference Complete",
description: "Visit all Western Conference arenas",
category: .conference,
sport: .nhl,
iconName: "hockey.puck.circle.fill",
iconColor: .blue,
requirement: .completeConference("nhl_western"),
sortOrder: 321,
conferenceId: "nhl_western"
)
]
// MARK: - League Achievements
static let leagueAchievements: [AchievementDefinition] = [
AchievementDefinition(
id: "mlb_complete",
name: "Diamond Collector",
description: "Visit all 30 MLB stadiums",
category: .league,
sport: .mlb,
iconName: "diamond.fill",
iconColor: .red,
requirement: .completeLeague(.mlb),
sortOrder: 400
),
AchievementDefinition(
id: "nba_complete",
name: "Court Master",
description: "Visit all 30 NBA arenas",
category: .league,
sport: .nba,
iconName: "trophy.fill",
iconColor: .orange,
requirement: .completeLeague(.nba),
sortOrder: 401
),
AchievementDefinition(
id: "nhl_complete",
name: "Ice Warrior",
description: "Visit all 32 NHL arenas",
category: .league,
sport: .nhl,
iconName: "crown.fill",
iconColor: .blue,
requirement: .completeLeague(.nhl),
sortOrder: 402
)
]
// MARK: - Journey Achievements
static let journeyAchievements: [AchievementDefinition] = [
AchievementDefinition(
id: "journey_weekend_warrior",
name: "Weekend Warrior",
description: "Visit 3 stadiums in 3 days",
category: .journey,
iconName: "figure.run",
iconColor: .green,
requirement: .visitsInDays(3, days: 3),
sortOrder: 500
),
AchievementDefinition(
id: "journey_road_trip",
name: "Road Trip Champion",
description: "Visit 5 stadiums in 7 days",
category: .journey,
iconName: "car.fill",
iconColor: .cyan,
requirement: .visitsInDays(5, days: 7),
sortOrder: 501
),
AchievementDefinition(
id: "journey_marathon",
name: "Stadium Marathon",
description: "Visit 7 stadiums in 10 days",
category: .journey,
iconName: "flame.fill",
iconColor: .orange,
requirement: .visitsInDays(7, days: 10),
sortOrder: 502
),
AchievementDefinition(
id: "journey_triple_threat",
name: "Triple Threat",
description: "Visit stadiums from all 3 leagues (MLB, NBA, NHL)",
category: .journey,
iconName: "star.fill",
iconColor: .yellow,
requirement: .multipleLeagues(3),
sortOrder: 503
)
]
// MARK: - Special Achievements
static let specialAchievements: [AchievementDefinition] = [
AchievementDefinition(
id: "special_fenway",
name: "Green Monster",
description: "Visit Fenway Park",
category: .special,
sport: .mlb,
iconName: "building.columns.fill",
iconColor: .green,
requirement: .specificStadium("stadium_mlb_bos"),
sortOrder: 600
),
AchievementDefinition(
id: "special_wrigley",
name: "Ivy League",
description: "Visit Wrigley Field",
category: .special,
sport: .mlb,
iconName: "leaf.fill",
iconColor: .green,
requirement: .specificStadium("stadium_mlb_chc"),
sortOrder: 601
),
AchievementDefinition(
id: "special_msg",
name: "World's Most Famous Arena",
description: "Visit Madison Square Garden",
category: .special,
sport: .nba,
iconName: "sparkles",
iconColor: .orange,
requirement: .specificStadium("stadium_nba_nyk"),
sortOrder: 602
)
]
// MARK: - Lookup Methods
/// Get achievement by ID
static func achievement(byId id: String) -> AchievementDefinition? {
all.first { $0.id == id }
}
/// Get achievements by category
static func achievements(forCategory category: AchievementCategory) -> [AchievementDefinition] {
all.filter { $0.category == category }
}
/// Get achievements for a sport
static func achievements(forSport sport: Sport) -> [AchievementDefinition] {
all.filter { $0.sport == sport || $0.sport == nil }
}
/// Get division achievements for a sport
static func divisionAchievements(forSport sport: Sport) -> [AchievementDefinition] {
all.filter { $0.sport == sport && $0.category == .division }
}
/// Get conference achievements for a sport
static func conferenceAchievements(forSport sport: Sport) -> [AchievementDefinition] {
all.filter { $0.sport == sport && $0.category == .conference }
}
}

View File

@@ -0,0 +1,119 @@
//
// Division.swift
// SportsTime
//
// Domain model for league structure: divisions and conferences.
//
import Foundation
// MARK: - Division
struct Division: Identifiable, Codable, Hashable {
let id: String // e.g., "mlb_nl_west"
let name: String // e.g., "NL West"
let conference: String // e.g., "National League"
let conferenceId: String // e.g., "mlb_nl"
let sport: Sport
var teamCanonicalIds: [String] // Canonical team IDs in this division
var teamCount: Int { teamCanonicalIds.count }
}
// MARK: - Conference
struct Conference: Identifiable, Codable, Hashable {
let id: String // e.g., "mlb_nl"
let name: String // e.g., "National League"
let abbreviation: String? // e.g., "NL"
let sport: Sport
let divisionIds: [String] // Division IDs in this conference
}
// MARK: - League Structure Provider
/// Provides access to league structure data (divisions, conferences).
/// Reads from SwiftData LeagueStructureModel and CanonicalTeam.
enum LeagueStructure {
// MARK: - Static Division Definitions
/// All MLB divisions
static let mlbDivisions: [Division] = [
Division(id: "mlb_al_east", name: "AL East", conference: "American League", conferenceId: "mlb_al", sport: .mlb, teamCanonicalIds: []),
Division(id: "mlb_al_central", name: "AL Central", conference: "American League", conferenceId: "mlb_al", sport: .mlb, teamCanonicalIds: []),
Division(id: "mlb_al_west", name: "AL West", conference: "American League", conferenceId: "mlb_al", sport: .mlb, teamCanonicalIds: []),
Division(id: "mlb_nl_east", name: "NL East", conference: "National League", conferenceId: "mlb_nl", sport: .mlb, teamCanonicalIds: []),
Division(id: "mlb_nl_central", name: "NL Central", conference: "National League", conferenceId: "mlb_nl", sport: .mlb, teamCanonicalIds: []),
Division(id: "mlb_nl_west", name: "NL West", conference: "National League", conferenceId: "mlb_nl", sport: .mlb, teamCanonicalIds: [])
]
/// All NBA divisions
static let nbaDivisions: [Division] = [
Division(id: "nba_atlantic", name: "Atlantic", conference: "Eastern Conference", conferenceId: "nba_eastern", sport: .nba, teamCanonicalIds: []),
Division(id: "nba_central", name: "Central", conference: "Eastern Conference", conferenceId: "nba_eastern", sport: .nba, teamCanonicalIds: []),
Division(id: "nba_southeast", name: "Southeast", conference: "Eastern Conference", conferenceId: "nba_eastern", sport: .nba, teamCanonicalIds: []),
Division(id: "nba_northwest", name: "Northwest", conference: "Western Conference", conferenceId: "nba_western", sport: .nba, teamCanonicalIds: []),
Division(id: "nba_pacific", name: "Pacific", conference: "Western Conference", conferenceId: "nba_western", sport: .nba, teamCanonicalIds: []),
Division(id: "nba_southwest", name: "Southwest", conference: "Western Conference", conferenceId: "nba_western", sport: .nba, teamCanonicalIds: [])
]
/// All NHL divisions
static let nhlDivisions: [Division] = [
Division(id: "nhl_atlantic", name: "Atlantic", conference: "Eastern Conference", conferenceId: "nhl_eastern", sport: .nhl, teamCanonicalIds: []),
Division(id: "nhl_metropolitan", name: "Metropolitan", conference: "Eastern Conference", conferenceId: "nhl_eastern", sport: .nhl, teamCanonicalIds: []),
Division(id: "nhl_central", name: "Central", conference: "Western Conference", conferenceId: "nhl_western", sport: .nhl, teamCanonicalIds: []),
Division(id: "nhl_pacific", name: "Pacific", conference: "Western Conference", conferenceId: "nhl_western", sport: .nhl, teamCanonicalIds: [])
]
/// All conferences
static let conferences: [Conference] = [
// MLB
Conference(id: "mlb_al", name: "American League", abbreviation: "AL", sport: .mlb, divisionIds: ["mlb_al_east", "mlb_al_central", "mlb_al_west"]),
Conference(id: "mlb_nl", name: "National League", abbreviation: "NL", sport: .mlb, divisionIds: ["mlb_nl_east", "mlb_nl_central", "mlb_nl_west"]),
// NBA
Conference(id: "nba_eastern", name: "Eastern Conference", abbreviation: "East", sport: .nba, divisionIds: ["nba_atlantic", "nba_central", "nba_southeast"]),
Conference(id: "nba_western", name: "Western Conference", abbreviation: "West", sport: .nba, divisionIds: ["nba_northwest", "nba_pacific", "nba_southwest"]),
// NHL
Conference(id: "nhl_eastern", name: "Eastern Conference", abbreviation: "East", sport: .nhl, divisionIds: ["nhl_atlantic", "nhl_metropolitan"]),
Conference(id: "nhl_western", name: "Western Conference", abbreviation: "West", sport: .nhl, divisionIds: ["nhl_central", "nhl_pacific"])
]
// MARK: - Lookup Methods
/// Get all divisions for a sport
static func divisions(for sport: Sport) -> [Division] {
switch sport {
case .mlb: return mlbDivisions
case .nba: return nbaDivisions
case .nhl: return nhlDivisions
default: return []
}
}
/// Get all conferences for a sport
static func conferences(for sport: Sport) -> [Conference] {
conferences.filter { $0.sport == sport }
}
/// Find division by ID
static func division(byId id: String) -> Division? {
let allDivisions = mlbDivisions + nbaDivisions + nhlDivisions
return allDivisions.first { $0.id == id }
}
/// Find conference by ID
static func conference(byId id: String) -> Conference? {
conferences.first { $0.id == id }
}
/// Get total stadium count for a sport
static func stadiumCount(for sport: Sport) -> Int {
switch sport {
case .mlb: return 30
case .nba: return 30
case .nhl: return 32
default: return 0
}
}
}

View File

@@ -0,0 +1,232 @@
//
// Progress.swift
// SportsTime
//
// Domain models for tracking stadium visit progress and achievements.
//
import Foundation
import SwiftUI
// MARK: - League Progress
/// Progress tracking for a single sport/league
struct LeagueProgress: Identifiable {
let sport: Sport
let totalStadiums: Int
let visitedStadiums: Int
let stadiumsVisited: [Stadium]
let stadiumsRemaining: [Stadium]
var id: String { sport.rawValue }
var completionPercentage: Double {
guard totalStadiums > 0 else { return 0 }
return Double(visitedStadiums) / Double(totalStadiums) * 100
}
var isComplete: Bool {
totalStadiums > 0 && visitedStadiums >= totalStadiums
}
var progressFraction: Double {
guard totalStadiums > 0 else { return 0 }
return Double(visitedStadiums) / Double(totalStadiums)
}
var progressDescription: String {
"\(visitedStadiums)/\(totalStadiums)"
}
}
// MARK: - Division Progress
/// Progress tracking for a single division
struct DivisionProgress: Identifiable {
let division: Division
let totalStadiums: Int
let visitedStadiums: Int
let stadiumsVisited: [Stadium]
let stadiumsRemaining: [Stadium]
var id: String { division.id }
var completionPercentage: Double {
guard totalStadiums > 0 else { return 0 }
return Double(visitedStadiums) / Double(totalStadiums) * 100
}
var isComplete: Bool {
totalStadiums > 0 && visitedStadiums >= totalStadiums
}
var progressFraction: Double {
guard totalStadiums > 0 else { return 0 }
return Double(visitedStadiums) / Double(totalStadiums)
}
}
// MARK: - Conference Progress
/// Progress tracking for a conference
struct ConferenceProgress: Identifiable {
let conference: Conference
let totalStadiums: Int
let visitedStadiums: Int
let divisionProgress: [DivisionProgress]
var id: String { conference.id }
var completionPercentage: Double {
guard totalStadiums > 0 else { return 0 }
return Double(visitedStadiums) / Double(totalStadiums) * 100
}
var isComplete: Bool {
totalStadiums > 0 && visitedStadiums >= totalStadiums
}
}
// MARK: - Overall Progress
/// Combined progress across all sports
struct OverallProgress {
let leagueProgress: [LeagueProgress]
let totalVisits: Int
let uniqueStadiumsVisited: Int
let totalStadiumsAcrossLeagues: Int
let achievementsEarned: Int
let totalAchievements: Int
var overallPercentage: Double {
guard totalStadiumsAcrossLeagues > 0 else { return 0 }
return Double(uniqueStadiumsVisited) / Double(totalStadiumsAcrossLeagues) * 100
}
/// Progress by sport
func progress(for sport: Sport) -> LeagueProgress? {
leagueProgress.first { $0.sport == sport }
}
}
// MARK: - Visit Summary
/// Summary of a stadium visit for display
struct VisitSummary: Identifiable {
let id: UUID
let stadium: Stadium
let visitDate: Date
let visitType: VisitType
let sport: Sport
let matchup: String?
let score: String?
let photoCount: Int
let notes: String?
var dateDescription: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
return formatter.string(from: visitDate)
}
var shortDateDescription: String {
let formatter = DateFormatter()
formatter.dateFormat = "MMM d, yyyy"
return formatter.string(from: visitDate)
}
}
// MARK: - Stadium Visit Status
/// Status of a stadium (visited or not, with visit info if applicable)
enum StadiumVisitStatus {
case visited(visits: [VisitSummary])
case notVisited
var isVisited: Bool {
if case .visited = self {
return true
}
return false
}
var visitCount: Int {
if case .visited(let visits) = self {
return visits.count
}
return 0
}
var latestVisit: VisitSummary? {
if case .visited(let visits) = self {
return visits.max(by: { $0.visitDate < $1.visitDate })
}
return nil
}
var firstVisit: VisitSummary? {
if case .visited(let visits) = self {
return visits.min(by: { $0.visitDate < $1.visitDate })
}
return nil
}
}
// MARK: - Progress Card Data
/// Data for generating shareable progress cards
struct ProgressCardData {
let sport: Sport
let progress: LeagueProgress
let username: String?
let includeMap: Bool
let showDetailedStats: Bool
var title: String {
"\(sport.displayName) Stadium Quest"
}
var subtitle: String {
"\(progress.visitedStadiums) of \(progress.totalStadiums) Stadiums"
}
var percentageText: String {
String(format: "%.0f%%", progress.completionPercentage)
}
}
// MARK: - Progress Card Options
struct ProgressCardOptions {
var includeUsername: Bool = true
var username: String?
var includeMapSnapshot: Bool = true
var includeStats: Bool = true
var cardStyle: CardStyle = .dark
enum CardStyle {
case dark
case light
var backgroundColor: Color {
switch self {
case .dark: return Color(hex: "1A1A2E")
case .light: return Color.white
}
}
var textColor: Color {
switch self {
case .dark: return .white
case .light: return Color(hex: "1A1A2E")
}
}
var secondaryTextColor: Color {
switch self {
case .dark: return Color(hex: "B8B8D1")
case .light: return Color(hex: "666666")
}
}
}
}

View File

@@ -14,6 +14,7 @@ struct Stadium: Identifiable, Codable, Hashable {
let latitude: Double
let longitude: Double
let capacity: Int
let sport: Sport
let yearOpened: Int?
let imageURL: URL?
@@ -25,6 +26,7 @@ struct Stadium: Identifiable, Codable, Hashable {
latitude: Double,
longitude: Double,
capacity: Int,
sport: Sport,
yearOpened: Int? = nil,
imageURL: URL? = nil
) {
@@ -35,6 +37,7 @@ struct Stadium: Identifiable, Codable, Hashable {
self.latitude = latitude
self.longitude = longitude
self.capacity = capacity
self.sport = sport
self.yearOpened = yearOpened
self.imageURL = imageURL
}

View File

@@ -58,7 +58,15 @@ struct Trip: Identifiable, Codable, Hashable {
return totalDrivingHours / Double(tripDuration)
}
var cities: [String] { stops.map { $0.city } }
var cities: [String] {
// Deduplicate while preserving order
var seen: Set<String> = []
return stops.compactMap { stop in
guard !seen.contains(stop.city) else { return nil }
seen.insert(stop.city)
return stop.city
}
}
var uniqueSports: Set<Sport> { preferences.sports }
var startDate: Date { stops.first?.arrivalDate ?? preferences.startDate }
var endDate: Date { stops.last?.departureDate ?? preferences.endDate }