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

@@ -15,6 +15,8 @@ enum CKRecordType {
static let stadium = "Stadium"
static let game = "Game"
static let sport = "Sport"
static let leagueStructure = "LeagueStructure"
static let teamAlias = "TeamAlias"
}
// MARK: - CKTeam
@@ -100,6 +102,7 @@ struct CKStadium {
static let capacityKey = "capacity"
static let yearOpenedKey = "yearOpened"
static let imageURLKey = "imageURL"
static let sportKey = "sport"
let record: CKRecord
@@ -117,6 +120,7 @@ struct CKStadium {
record[CKStadium.capacityKey] = stadium.capacity
record[CKStadium.yearOpenedKey] = stadium.yearOpened
record[CKStadium.imageURLKey] = stadium.imageURL?.absoluteString
record[CKStadium.sportKey] = stadium.sport.rawValue
self.record = record
}
@@ -133,6 +137,8 @@ struct CKStadium {
let location = record[CKStadium.locationKey] as? CLLocation
let capacity = record[CKStadium.capacityKey] as? Int ?? 0
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) }
let sportRaw = record[CKStadium.sportKey] as? String ?? "MLB"
let sport = Sport(rawValue: sportRaw) ?? .mlb
return Stadium(
id: id,
@@ -142,6 +148,7 @@ struct CKStadium {
latitude: location?.coordinate.latitude ?? 0,
longitude: location?.coordinate.longitude ?? 0,
capacity: capacity,
sport: sport,
yearOpened: record[CKStadium.yearOpenedKey] as? Int,
imageURL: imageURL
)
@@ -203,3 +210,123 @@ struct CKGame {
)
}
}
// MARK: - CKLeagueStructure
struct CKLeagueStructure {
static let idKey = "structureId"
static let sportKey = "sport"
static let typeKey = "type"
static let nameKey = "name"
static let abbreviationKey = "abbreviation"
static let parentIdKey = "parentId"
static let displayOrderKey = "displayOrder"
static let schemaVersionKey = "schemaVersion"
static let lastModifiedKey = "lastModified"
let record: CKRecord
init(record: CKRecord) {
self.record = record
}
init(model: LeagueStructureModel) {
let record = CKRecord(recordType: CKRecordType.leagueStructure, recordID: CKRecord.ID(recordName: model.id))
record[CKLeagueStructure.idKey] = model.id
record[CKLeagueStructure.sportKey] = model.sport
record[CKLeagueStructure.typeKey] = model.structureTypeRaw
record[CKLeagueStructure.nameKey] = model.name
record[CKLeagueStructure.abbreviationKey] = model.abbreviation
record[CKLeagueStructure.parentIdKey] = model.parentId
record[CKLeagueStructure.displayOrderKey] = model.displayOrder
record[CKLeagueStructure.schemaVersionKey] = model.schemaVersion
record[CKLeagueStructure.lastModifiedKey] = model.lastModified
self.record = record
}
/// Convert to SwiftData model for local storage
func toModel() -> LeagueStructureModel? {
guard let id = record[CKLeagueStructure.idKey] as? String,
let sport = record[CKLeagueStructure.sportKey] as? String,
let typeRaw = record[CKLeagueStructure.typeKey] as? String,
let structureType = LeagueStructureType(rawValue: typeRaw),
let name = record[CKLeagueStructure.nameKey] as? String
else { return nil }
let abbreviation = record[CKLeagueStructure.abbreviationKey] as? String
let parentId = record[CKLeagueStructure.parentIdKey] as? String
let displayOrder = record[CKLeagueStructure.displayOrderKey] as? Int ?? 0
let schemaVersion = record[CKLeagueStructure.schemaVersionKey] as? Int ?? SchemaVersion.current
let lastModified = record[CKLeagueStructure.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
return LeagueStructureModel(
id: id,
sport: sport,
structureType: structureType,
name: name,
abbreviation: abbreviation,
parentId: parentId,
displayOrder: displayOrder,
schemaVersion: schemaVersion,
lastModified: lastModified
)
}
}
// MARK: - CKTeamAlias
struct CKTeamAlias {
static let idKey = "aliasId"
static let teamCanonicalIdKey = "teamCanonicalId"
static let aliasTypeKey = "aliasType"
static let aliasValueKey = "aliasValue"
static let validFromKey = "validFrom"
static let validUntilKey = "validUntil"
static let schemaVersionKey = "schemaVersion"
static let lastModifiedKey = "lastModified"
let record: CKRecord
init(record: CKRecord) {
self.record = record
}
init(model: TeamAlias) {
let record = CKRecord(recordType: CKRecordType.teamAlias, recordID: CKRecord.ID(recordName: model.id))
record[CKTeamAlias.idKey] = model.id
record[CKTeamAlias.teamCanonicalIdKey] = model.teamCanonicalId
record[CKTeamAlias.aliasTypeKey] = model.aliasTypeRaw
record[CKTeamAlias.aliasValueKey] = model.aliasValue
record[CKTeamAlias.validFromKey] = model.validFrom
record[CKTeamAlias.validUntilKey] = model.validUntil
record[CKTeamAlias.schemaVersionKey] = model.schemaVersion
record[CKTeamAlias.lastModifiedKey] = model.lastModified
self.record = record
}
/// Convert to SwiftData model for local storage
func toModel() -> TeamAlias? {
guard let id = record[CKTeamAlias.idKey] as? String,
let teamCanonicalId = record[CKTeamAlias.teamCanonicalIdKey] as? String,
let aliasTypeRaw = record[CKTeamAlias.aliasTypeKey] as? String,
let aliasType = TeamAliasType(rawValue: aliasTypeRaw),
let aliasValue = record[CKTeamAlias.aliasValueKey] as? String
else { return nil }
let validFrom = record[CKTeamAlias.validFromKey] as? Date
let validUntil = record[CKTeamAlias.validUntilKey] as? Date
let schemaVersion = record[CKTeamAlias.schemaVersionKey] as? Int ?? SchemaVersion.current
let lastModified = record[CKTeamAlias.lastModifiedKey] as? Date ?? record.modificationDate ?? Date()
return TeamAlias(
id: id,
teamCanonicalId: teamCanonicalId,
aliasType: aliasType,
aliasValue: aliasValue,
validFrom: validFrom,
validUntil: validUntil,
schemaVersion: schemaVersion,
lastModified: lastModified
)
}
}

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 }

View File

@@ -0,0 +1,492 @@
//
// CanonicalModels.swift
// SportsTime
//
// SwiftData models for canonical data: stadiums, teams, games, and league structure.
// These are the runtime source of truth, populated from bundled JSON and synced via CloudKit.
//
import Foundation
import SwiftData
import CryptoKit
// MARK: - Schema Version
/// Schema version constants for canonical data models.
/// Marked nonisolated to allow access from any isolation domain.
nonisolated enum SchemaVersion {
static let current: Int = 1
static let minimumSupported: Int = 1
}
// MARK: - Data Source
enum DataSource: String, Codable {
case bundled // Shipped with app bundle
case cloudKit // Synced from CloudKit
case userCorrection // User-provided correction
}
// MARK: - Team Alias Type
enum TeamAliasType: String, Codable {
case abbreviation // Old abbreviation (e.g., "NJN" -> "BRK")
case name // Old team name (e.g., "New Jersey Nets")
case city // Old city (e.g., "New Jersey")
}
// MARK: - League Structure Type
enum LeagueStructureType: String, Codable {
case conference
case division
case league
}
// MARK: - Sync State
@Model
final class SyncState {
@Attribute(.unique) var id: String = "singleton"
// Bootstrap tracking
var bootstrapCompleted: Bool = false
var bundledSchemaVersion: Int = 0
var lastBootstrap: Date?
// CloudKit sync tracking
var lastSuccessfulSync: Date?
var lastSyncAttempt: Date?
var lastSyncError: String?
var syncInProgress: Bool = false
var syncEnabled: Bool = true
var syncPausedReason: String?
var consecutiveFailures: Int = 0
// Change tokens for delta sync
var stadiumChangeToken: Data?
var teamChangeToken: Data?
var gameChangeToken: Data?
var leagueChangeToken: Data?
init() {}
static func current(in context: ModelContext) -> SyncState {
let descriptor = FetchDescriptor<SyncState>(
predicate: #Predicate { $0.id == "singleton" }
)
if let existing = try? context.fetch(descriptor).first {
return existing
}
let new = SyncState()
context.insert(new)
return new
}
}
// MARK: - Canonical Stadium
@Model
final class CanonicalStadium {
// Identity
@Attribute(.unique) var canonicalId: String
var uuid: UUID
// Versioning
var schemaVersion: Int
var lastModified: Date
var sourceRaw: String
// Deprecation (soft delete)
var deprecatedAt: Date?
var deprecationReason: String?
var replacedByCanonicalId: String?
// Core data
var name: String
var city: String
var state: String
var latitude: Double
var longitude: Double
var capacity: Int
var yearOpened: Int?
var imageURL: String?
var sport: String
// User-correctable fields (preserved during sync)
var userNickname: String?
var userNotes: String?
var isFavorite: Bool = false
// Relationships
@Relationship(deleteRule: .cascade, inverse: \StadiumAlias.stadium)
var aliases: [StadiumAlias]?
init(
canonicalId: String,
uuid: UUID? = nil,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date(),
source: DataSource = .bundled,
name: String,
city: String,
state: String,
latitude: Double,
longitude: Double,
capacity: Int,
yearOpened: Int? = nil,
imageURL: String? = nil,
sport: String
) {
self.canonicalId = canonicalId
self.uuid = uuid ?? Self.deterministicUUID(from: canonicalId)
self.schemaVersion = schemaVersion
self.lastModified = lastModified
self.sourceRaw = source.rawValue
self.name = name
self.city = city
self.state = state
self.latitude = latitude
self.longitude = longitude
self.capacity = capacity
self.yearOpened = yearOpened
self.imageURL = imageURL
self.sport = sport
}
var source: DataSource {
get { DataSource(rawValue: sourceRaw) ?? .bundled }
set { sourceRaw = newValue.rawValue }
}
var isActive: Bool { deprecatedAt == nil }
func toDomain() -> Stadium {
Stadium(
id: uuid,
name: name,
city: city,
state: state,
latitude: latitude,
longitude: longitude,
capacity: capacity,
sport: Sport(rawValue: sport) ?? .mlb,
yearOpened: yearOpened,
imageURL: imageURL.flatMap { URL(string: $0) }
)
}
static func deterministicUUID(from string: String) -> UUID {
let data = Data(string.utf8)
let hash = SHA256.hash(data: data)
let hashBytes = Array(hash)
var bytes = Array(hashBytes.prefix(16))
// Set version 4 and variant bits
bytes[6] = (bytes[6] & 0x0F) | 0x40
bytes[8] = (bytes[8] & 0x3F) | 0x80
return UUID(uuid: (
bytes[0], bytes[1], bytes[2], bytes[3],
bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11],
bytes[12], bytes[13], bytes[14], bytes[15]
))
}
}
// MARK: - Stadium Alias
@Model
final class StadiumAlias {
@Attribute(.unique) var aliasName: String
var stadiumCanonicalId: String
var validFrom: Date?
var validUntil: Date?
var schemaVersion: Int
var lastModified: Date
var stadium: CanonicalStadium?
init(
aliasName: String,
stadiumCanonicalId: String,
validFrom: Date? = nil,
validUntil: Date? = nil,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date()
) {
self.aliasName = aliasName.lowercased()
self.stadiumCanonicalId = stadiumCanonicalId
self.validFrom = validFrom
self.validUntil = validUntil
self.schemaVersion = schemaVersion
self.lastModified = lastModified
}
}
// MARK: - Canonical Team
@Model
final class CanonicalTeam {
@Attribute(.unique) var canonicalId: String
var uuid: UUID
var schemaVersion: Int
var lastModified: Date
var sourceRaw: String
var deprecatedAt: Date?
var deprecationReason: String?
var relocatedToCanonicalId: String?
var name: String
var abbreviation: String
var sport: String
var city: String
var stadiumCanonicalId: String
var logoURL: String?
var primaryColor: String?
var secondaryColor: String?
var conferenceId: String?
var divisionId: String?
// User-correctable
var userNickname: String?
var isFavorite: Bool = false
@Relationship(deleteRule: .cascade, inverse: \TeamAlias.team)
var aliases: [TeamAlias]?
init(
canonicalId: String,
uuid: UUID? = nil,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date(),
source: DataSource = .bundled,
name: String,
abbreviation: String,
sport: String,
city: String,
stadiumCanonicalId: String,
logoURL: String? = nil,
primaryColor: String? = nil,
secondaryColor: String? = nil,
conferenceId: String? = nil,
divisionId: String? = nil
) {
self.canonicalId = canonicalId
self.uuid = uuid ?? CanonicalStadium.deterministicUUID(from: canonicalId)
self.schemaVersion = schemaVersion
self.lastModified = lastModified
self.sourceRaw = source.rawValue
self.name = name
self.abbreviation = abbreviation
self.sport = sport
self.city = city
self.stadiumCanonicalId = stadiumCanonicalId
self.logoURL = logoURL
self.primaryColor = primaryColor
self.secondaryColor = secondaryColor
self.conferenceId = conferenceId
self.divisionId = divisionId
}
var source: DataSource {
get { DataSource(rawValue: sourceRaw) ?? .bundled }
set { sourceRaw = newValue.rawValue }
}
var isActive: Bool { deprecatedAt == nil }
var sportEnum: Sport? { Sport(rawValue: sport) }
func toDomain(stadiumUUID: UUID) -> Team {
Team(
id: uuid,
name: name,
abbreviation: abbreviation,
sport: sportEnum ?? .mlb,
city: city,
stadiumId: stadiumUUID,
logoURL: logoURL.flatMap { URL(string: $0) },
primaryColor: primaryColor,
secondaryColor: secondaryColor
)
}
}
// MARK: - Team Alias
@Model
final class TeamAlias {
@Attribute(.unique) var id: String
var teamCanonicalId: String
var aliasTypeRaw: String
var aliasValue: String
var validFrom: Date?
var validUntil: Date?
var schemaVersion: Int
var lastModified: Date
var team: CanonicalTeam?
init(
id: String,
teamCanonicalId: String,
aliasType: TeamAliasType,
aliasValue: String,
validFrom: Date? = nil,
validUntil: Date? = nil,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date()
) {
self.id = id
self.teamCanonicalId = teamCanonicalId
self.aliasTypeRaw = aliasType.rawValue
self.aliasValue = aliasValue
self.validFrom = validFrom
self.validUntil = validUntil
self.schemaVersion = schemaVersion
self.lastModified = lastModified
}
var aliasType: TeamAliasType {
get { TeamAliasType(rawValue: aliasTypeRaw) ?? .name }
set { aliasTypeRaw = newValue.rawValue }
}
}
// MARK: - League Structure
@Model
final class LeagueStructureModel {
@Attribute(.unique) var id: String
var sport: String
var structureTypeRaw: String
var name: String
var abbreviation: String?
var parentId: String?
var displayOrder: Int
var schemaVersion: Int
var lastModified: Date
init(
id: String,
sport: String,
structureType: LeagueStructureType,
name: String,
abbreviation: String? = nil,
parentId: String? = nil,
displayOrder: Int = 0,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date()
) {
self.id = id
self.sport = sport
self.structureTypeRaw = structureType.rawValue
self.name = name
self.abbreviation = abbreviation
self.parentId = parentId
self.displayOrder = displayOrder
self.schemaVersion = schemaVersion
self.lastModified = lastModified
}
var structureType: LeagueStructureType {
get { LeagueStructureType(rawValue: structureTypeRaw) ?? .division }
set { structureTypeRaw = newValue.rawValue }
}
var sportEnum: Sport? { Sport(rawValue: sport) }
}
// MARK: - Canonical Game
@Model
final class CanonicalGame {
@Attribute(.unique) var canonicalId: String
var uuid: UUID
var schemaVersion: Int
var lastModified: Date
var sourceRaw: String
var deprecatedAt: Date?
var deprecationReason: String?
var rescheduledToCanonicalId: String?
var homeTeamCanonicalId: String
var awayTeamCanonicalId: String
var stadiumCanonicalId: String
var dateTime: Date
var sport: String
var season: String
var isPlayoff: Bool
var broadcastInfo: String?
// User-correctable
var userAttending: Bool = false
var userNotes: String?
init(
canonicalId: String,
uuid: UUID? = nil,
schemaVersion: Int = SchemaVersion.current,
lastModified: Date = Date(),
source: DataSource = .bundled,
homeTeamCanonicalId: String,
awayTeamCanonicalId: String,
stadiumCanonicalId: String,
dateTime: Date,
sport: String,
season: String,
isPlayoff: Bool = false,
broadcastInfo: String? = nil
) {
self.canonicalId = canonicalId
self.uuid = uuid ?? CanonicalStadium.deterministicUUID(from: canonicalId)
self.schemaVersion = schemaVersion
self.lastModified = lastModified
self.sourceRaw = source.rawValue
self.homeTeamCanonicalId = homeTeamCanonicalId
self.awayTeamCanonicalId = awayTeamCanonicalId
self.stadiumCanonicalId = stadiumCanonicalId
self.dateTime = dateTime
self.sport = sport
self.season = season
self.isPlayoff = isPlayoff
self.broadcastInfo = broadcastInfo
}
var source: DataSource {
get { DataSource(rawValue: sourceRaw) ?? .bundled }
set { sourceRaw = newValue.rawValue }
}
var isActive: Bool { deprecatedAt == nil }
var sportEnum: Sport? { Sport(rawValue: sport) }
func toDomain(homeTeamUUID: UUID, awayTeamUUID: UUID, stadiumUUID: UUID) -> Game {
Game(
id: uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
dateTime: dateTime,
sport: sportEnum ?? .mlb,
season: season,
isPlayoff: isPlayoff,
broadcastInfo: broadcastInfo
)
}
}
// MARK: - Bundled Data Timestamps
/// Timestamps for bundled data files.
/// Marked nonisolated to allow access from any isolation domain.
nonisolated enum BundledDataTimestamp {
static let stadiums = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
static let games = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
static let leagueStructure = ISO8601DateFormatter().date(from: "2026-01-08T00:00:00Z") ?? Date()
}

View File

@@ -0,0 +1,364 @@
//
// StadiumProgress.swift
// SportsTime
//
// SwiftData models for tracking stadium visits, achievements, and photo metadata.
//
import Foundation
import SwiftData
// MARK: - Visit Type
enum VisitType: String, Codable, CaseIterable {
case game // Attended a game
case tour // Stadium tour without game
case other // Other visit (tailgating, event, etc.)
var displayName: String {
switch self {
case .game: return "Game"
case .tour: return "Tour"
case .other: return "Other"
}
}
}
// MARK: - Data Source Type
enum VisitDataSource: String, Codable {
case automatic // All data from photo + API
case partialManual // Photo metadata + manual game selection
case fullyManual // User entered everything
case userCorrected // Was automatic, user edited
}
// MARK: - Score Source Type
enum ScoreSource: String, Codable {
case app // From app's schedule data
case api // From free sports API
case scraped // From reference site scraping
case user // User-provided
}
// MARK: - Visit Source Type
enum VisitSource: String, Codable {
case trip // Created from a planned trip
case manual // Manually entered
case photoImport // Imported from photo library
}
// MARK: - Upload Status
enum UploadStatus: String, Codable {
case pending
case uploaded
case failed
}
// MARK: - Stadium Visit
@Model
final class StadiumVisit {
@Attribute(.unique) var id: UUID
// Stadium identity (stable across renames)
var canonicalStadiumId: String // Links to CanonicalStadium.canonicalId
var stadiumUUID: UUID // Runtime UUID for display lookups
var stadiumNameAtVisit: String // Frozen at visit time
// Visit details
var visitDate: Date
var sport: String // Sport.rawValue
var visitTypeRaw: String // VisitType.rawValue
// Game info (optional - nil for tours/other visits)
var gameId: UUID?
var homeTeamId: UUID?
var awayTeamId: UUID?
var homeTeamName: String? // For display when team lookup fails
var awayTeamName: String?
var finalScore: String? // "5-3" format
var manualGameDescription: String? // User's description if game not found
// Resolution tracking
var scoreSourceRaw: String? // ScoreSource.rawValue
var dataSourceRaw: String // VisitDataSource.rawValue
var scoreResolutionPending: Bool // true if background retry needed
// User data
var seatLocation: String?
var notes: String?
// Photos
@Relationship(deleteRule: .cascade, inverse: \VisitPhotoMetadata.visit)
var photoMetadata: [VisitPhotoMetadata]?
// Photo import metadata (preserved for debugging/re-matching)
var photoLatitude: Double?
var photoLongitude: Double?
var photoCaptureDate: Date?
// Audit
var createdAt: Date
var sourceRaw: String // VisitSource.rawValue
// MARK: - Initialization
init(
id: UUID = UUID(),
canonicalStadiumId: String,
stadiumUUID: UUID,
stadiumNameAtVisit: String,
visitDate: Date,
sport: Sport,
visitType: VisitType = .game,
gameId: UUID? = nil,
homeTeamId: UUID? = nil,
awayTeamId: UUID? = nil,
homeTeamName: String? = nil,
awayTeamName: String? = nil,
finalScore: String? = nil,
manualGameDescription: String? = nil,
scoreSource: ScoreSource? = nil,
dataSource: VisitDataSource = .fullyManual,
scoreResolutionPending: Bool = false,
seatLocation: String? = nil,
notes: String? = nil,
photoLatitude: Double? = nil,
photoLongitude: Double? = nil,
photoCaptureDate: Date? = nil,
source: VisitSource = .manual
) {
self.id = id
self.canonicalStadiumId = canonicalStadiumId
self.stadiumUUID = stadiumUUID
self.stadiumNameAtVisit = stadiumNameAtVisit
self.visitDate = visitDate
self.sport = sport.rawValue
self.visitTypeRaw = visitType.rawValue
self.gameId = gameId
self.homeTeamId = homeTeamId
self.awayTeamId = awayTeamId
self.homeTeamName = homeTeamName
self.awayTeamName = awayTeamName
self.finalScore = finalScore
self.manualGameDescription = manualGameDescription
self.scoreSourceRaw = scoreSource?.rawValue
self.dataSourceRaw = dataSource.rawValue
self.scoreResolutionPending = scoreResolutionPending
self.seatLocation = seatLocation
self.notes = notes
self.photoLatitude = photoLatitude
self.photoLongitude = photoLongitude
self.photoCaptureDate = photoCaptureDate
self.createdAt = Date()
self.sourceRaw = source.rawValue
}
// MARK: - Computed Properties
var visitType: VisitType {
get { VisitType(rawValue: visitTypeRaw) ?? .game }
set { visitTypeRaw = newValue.rawValue }
}
var scoreSource: ScoreSource? {
get { scoreSourceRaw.flatMap { ScoreSource(rawValue: $0) } }
set { scoreSourceRaw = newValue?.rawValue }
}
var dataSource: VisitDataSource {
get { VisitDataSource(rawValue: dataSourceRaw) ?? .fullyManual }
set { dataSourceRaw = newValue.rawValue }
}
var source: VisitSource {
get { VisitSource(rawValue: sourceRaw) ?? .manual }
set { sourceRaw = newValue.rawValue }
}
var sportEnum: Sport? {
Sport(rawValue: sport)
}
/// Display string for the game matchup
var matchupDescription: String? {
if let home = homeTeamName, let away = awayTeamName {
return "\(away) @ \(home)"
}
return manualGameDescription
}
/// Display string including score if available
var matchupWithScore: String? {
guard let matchup = matchupDescription else { return nil }
if let score = finalScore {
return "\(matchup) (\(score))"
}
return matchup
}
}
// MARK: - Visit Photo Metadata
@Model
final class VisitPhotoMetadata {
@Attribute(.unique) var id: UUID
var visitId: UUID
var cloudKitAssetId: String? // Set after successful upload
var thumbnailData: Data? // 200x200 JPEG for fast loading
var caption: String?
var orderIndex: Int
var uploadStatusRaw: String // UploadStatus.rawValue
var createdAt: Date
var visit: StadiumVisit?
init(
id: UUID = UUID(),
visitId: UUID,
cloudKitAssetId: String? = nil,
thumbnailData: Data? = nil,
caption: String? = nil,
orderIndex: Int = 0,
uploadStatus: UploadStatus = .pending
) {
self.id = id
self.visitId = visitId
self.cloudKitAssetId = cloudKitAssetId
self.thumbnailData = thumbnailData
self.caption = caption
self.orderIndex = orderIndex
self.uploadStatusRaw = uploadStatus.rawValue
self.createdAt = Date()
}
var uploadStatus: UploadStatus {
get { UploadStatus(rawValue: uploadStatusRaw) ?? .pending }
set { uploadStatusRaw = newValue.rawValue }
}
}
// MARK: - Achievement
@Model
final class Achievement {
@Attribute(.unique) var id: UUID
var achievementTypeId: String // e.g., "mlb_all_30", "nl_west_complete"
var sport: String? // Sport.rawValue, nil for cross-sport achievements
var earnedAt: Date
var revokedAt: Date? // Non-nil if visits deleted
var visitIdsSnapshot: Data // JSON-encoded [UUID] that earned this
init(
id: UUID = UUID(),
achievementTypeId: String,
sport: Sport? = nil,
earnedAt: Date = Date(),
visitIds: [UUID]
) {
self.id = id
self.achievementTypeId = achievementTypeId
self.sport = sport?.rawValue
self.earnedAt = earnedAt
self.revokedAt = nil
self.visitIdsSnapshot = (try? JSONEncoder().encode(visitIds)) ?? Data()
}
var sportEnum: Sport? {
sport.flatMap { Sport(rawValue: $0) }
}
var visitIds: [UUID] {
(try? JSONDecoder().decode([UUID].self, from: visitIdsSnapshot)) ?? []
}
var isEarned: Bool {
revokedAt == nil
}
func revoke() {
revokedAt = Date()
}
func restore() {
revokedAt = nil
}
}
// MARK: - Cached Game Score
/// Caches resolved game scores to avoid repeated API calls.
/// Historical scores never change, so they can be cached indefinitely.
@Model
final class CachedGameScore {
@Attribute(.unique) var cacheKey: String // "MLB_2010-06-15_SFG_LAD"
var sport: String
var gameDate: Date
var homeTeamAbbrev: String
var awayTeamAbbrev: String
var homeTeamName: String
var awayTeamName: String
var homeScore: Int?
var awayScore: Int?
var sourceRaw: String // ScoreSource.rawValue
var fetchedAt: Date
var expiresAt: Date? // nil = never expires (historical data)
init(
cacheKey: String,
sport: Sport,
gameDate: Date,
homeTeamAbbrev: String,
awayTeamAbbrev: String,
homeTeamName: String,
awayTeamName: String,
homeScore: Int?,
awayScore: Int?,
source: ScoreSource,
expiresAt: Date? = nil
) {
self.cacheKey = cacheKey
self.sport = sport.rawValue
self.gameDate = gameDate
self.homeTeamAbbrev = homeTeamAbbrev
self.awayTeamAbbrev = awayTeamAbbrev
self.homeTeamName = homeTeamName
self.awayTeamName = awayTeamName
self.homeScore = homeScore
self.awayScore = awayScore
self.sourceRaw = source.rawValue
self.fetchedAt = Date()
self.expiresAt = expiresAt
}
var scoreSource: ScoreSource {
get { ScoreSource(rawValue: sourceRaw) ?? .api }
set { sourceRaw = newValue.rawValue }
}
var sportEnum: Sport? {
Sport(rawValue: sport)
}
var scoreString: String? {
guard let home = homeScore, let away = awayScore else { return nil }
return "\(away)-\(home)"
}
var isExpired: Bool {
guard let expiresAt = expiresAt else { return false }
return Date() > expiresAt
}
/// Generate cache key for a game query
static func generateKey(sport: Sport, date: Date, homeAbbrev: String, awayAbbrev: String) -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let dateString = dateFormatter.string(from: date)
return "\(sport.rawValue)_\(dateString)_\(homeAbbrev)_\(awayAbbrev)"
}
}

View File

@@ -0,0 +1,444 @@
//
// AchievementEngine.swift
// SportsTime
//
// Computes achievements based on stadium visits.
// Recalculates and revokes achievements when visits are deleted.
//
import Foundation
import SwiftData
// MARK: - Achievement Delta
struct AchievementDelta: Sendable {
let newlyEarned: [AchievementDefinition]
let revoked: [AchievementDefinition]
let stillEarned: [AchievementDefinition]
var hasChanges: Bool {
!newlyEarned.isEmpty || !revoked.isEmpty
}
}
// MARK: - Achievement Engine
@MainActor
final class AchievementEngine {
// MARK: - Properties
private let modelContext: ModelContext
private let dataProvider: AppDataProvider
// MARK: - Initialization
init(modelContext: ModelContext, dataProvider: AppDataProvider = AppDataProvider.shared) {
self.modelContext = modelContext
self.dataProvider = dataProvider
}
// MARK: - Public API
/// Full recalculation (call after visit deleted or on app update)
func recalculateAllAchievements() async throws -> AchievementDelta {
// Get all visits
let visits = try fetchAllVisits()
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
// Get currently earned achievements
let currentAchievements = try fetchEarnedAchievements()
let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId })
// Calculate which achievements should be earned
var shouldBeEarned: Set<String> = []
var newlyEarnedDefinitions: [AchievementDefinition] = []
var revokedDefinitions: [AchievementDefinition] = []
var stillEarnedDefinitions: [AchievementDefinition] = []
for definition in AchievementRegistry.all {
let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds)
if isEarned {
shouldBeEarned.insert(definition.id)
if currentAchievementIds.contains(definition.id) {
stillEarnedDefinitions.append(definition)
} else {
newlyEarnedDefinitions.append(definition)
}
} else if currentAchievementIds.contains(definition.id) {
revokedDefinitions.append(definition)
}
}
// Apply changes
// Grant new achievements
for definition in newlyEarnedDefinitions {
let visitIds = getContributingVisitIds(for: definition.requirement, visits: visits)
let achievement = Achievement(
achievementTypeId: definition.id,
sport: definition.sport,
visitIds: visitIds
)
modelContext.insert(achievement)
}
// Revoke achievements
for definition in revokedDefinitions {
if let achievement = currentAchievements.first(where: { $0.achievementTypeId == definition.id }) {
achievement.revoke()
}
}
// Restore previously revoked achievements that are now earned again
for definition in stillEarnedDefinitions {
if let achievement = currentAchievements.first(where: {
$0.achievementTypeId == definition.id && $0.revokedAt != nil
}) {
achievement.restore()
}
}
try modelContext.save()
return AchievementDelta(
newlyEarned: newlyEarnedDefinitions,
revoked: revokedDefinitions,
stillEarned: stillEarnedDefinitions
)
}
/// Quick check after new visit (incremental)
func checkAchievementsForNewVisit(_ visit: StadiumVisit) async throws -> [AchievementDefinition] {
let visits = try fetchAllVisits()
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
let currentAchievements = try fetchEarnedAchievements()
let currentAchievementIds = Set(currentAchievements.map { $0.achievementTypeId })
var newlyEarned: [AchievementDefinition] = []
for definition in AchievementRegistry.all {
// Skip already earned
guard !currentAchievementIds.contains(definition.id) else { continue }
let isEarned = checkRequirement(definition.requirement, visits: visits, visitedStadiumIds: visitedStadiumIds)
if isEarned {
newlyEarned.append(definition)
let visitIds = getContributingVisitIds(for: definition.requirement, visits: visits)
let achievement = Achievement(
achievementTypeId: definition.id,
sport: definition.sport,
visitIds: visitIds
)
modelContext.insert(achievement)
}
}
try modelContext.save()
return newlyEarned
}
/// Get all earned achievements
func getEarnedAchievements() throws -> [AchievementDefinition] {
let achievements = try fetchEarnedAchievements()
return achievements.compactMap { AchievementRegistry.achievement(byId: $0.achievementTypeId) }
}
/// Get progress toward all achievements
func getProgress() async throws -> [AchievementProgress] {
let visits = try fetchAllVisits()
let visitedStadiumIds = Set(visits.map { $0.canonicalStadiumId })
let earnedAchievements = try fetchEarnedAchievements()
let earnedIds = Set(earnedAchievements.map { $0.achievementTypeId })
var progress: [AchievementProgress] = []
for definition in AchievementRegistry.all {
let (current, total) = calculateProgress(
for: definition.requirement,
visits: visits,
visitedStadiumIds: visitedStadiumIds
)
let isEarned = earnedIds.contains(definition.id)
let earnedAt = earnedAchievements.first(where: { $0.achievementTypeId == definition.id })?.earnedAt
progress.append(AchievementProgress(
definition: definition,
currentProgress: current,
totalRequired: total,
isEarned: isEarned,
earnedAt: earnedAt
))
}
return progress
}
// MARK: - Requirement Checking
private func checkRequirement(
_ requirement: AchievementRequirement,
visits: [StadiumVisit],
visitedStadiumIds: Set<String>
) -> Bool {
switch requirement {
case .firstVisit:
return !visits.isEmpty
case .visitCount(let count):
return visitedStadiumIds.count >= count
case .visitCountForSport(let count, let sport):
let sportVisits = visits.filter { $0.sport == sport.rawValue }
let sportStadiums = Set(sportVisits.map { $0.canonicalStadiumId })
return sportStadiums.count >= count
case .completeDivision(let divisionId):
return checkDivisionComplete(divisionId, visitedStadiumIds: visitedStadiumIds)
case .completeConference(let conferenceId):
return checkConferenceComplete(conferenceId, visitedStadiumIds: visitedStadiumIds)
case .completeLeague(let sport):
return checkLeagueComplete(sport, visitedStadiumIds: visitedStadiumIds)
case .visitsInDays(let visitCount, let days):
return checkVisitsInDays(visits: visits, requiredVisits: visitCount, withinDays: days)
case .multipleLeagues(let leagueCount):
return checkMultipleLeagues(visits: visits, requiredLeagues: leagueCount)
case .specificStadium(let stadiumId):
return visitedStadiumIds.contains(stadiumId)
}
}
private func checkDivisionComplete(_ divisionId: String, visitedStadiumIds: Set<String>) -> Bool {
guard let division = LeagueStructure.division(byId: divisionId) else { return false }
// Get stadium IDs for teams in this division
let stadiumIds = getStadiumIdsForDivision(divisionId)
guard !stadiumIds.isEmpty else { return false }
return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) }
}
private func checkConferenceComplete(_ conferenceId: String, visitedStadiumIds: Set<String>) -> Bool {
guard let conference = LeagueStructure.conference(byId: conferenceId) else { return false }
// Get stadium IDs for all teams in this conference
let stadiumIds = getStadiumIdsForConference(conferenceId)
guard !stadiumIds.isEmpty else { return false }
return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) }
}
private func checkLeagueComplete(_ sport: Sport, visitedStadiumIds: Set<String>) -> Bool {
let stadiumIds = getStadiumIdsForLeague(sport)
guard !stadiumIds.isEmpty else { return false }
return stadiumIds.allSatisfy { visitedStadiumIds.contains($0) }
}
private func checkVisitsInDays(visits: [StadiumVisit], requiredVisits: Int, withinDays: Int) -> Bool {
guard visits.count >= requiredVisits else { return false }
// Sort visits by date
let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate }
// Sliding window
for i in 0...(sortedVisits.count - requiredVisits) {
let windowStart = sortedVisits[i].visitDate
let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate
let daysDiff = Calendar.current.dateComponents([.day], from: windowStart, to: windowEnd).day ?? Int.max
if daysDiff < withinDays {
// Check unique stadiums in window
let windowVisits = Array(sortedVisits[i..<(i + requiredVisits)])
let uniqueStadiums = Set(windowVisits.map { $0.canonicalStadiumId })
if uniqueStadiums.count >= requiredVisits {
return true
}
}
}
return false
}
private func checkMultipleLeagues(visits: [StadiumVisit], requiredLeagues: Int) -> Bool {
let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) })
return leagues.count >= requiredLeagues
}
// MARK: - Progress Calculation
private func calculateProgress(
for requirement: AchievementRequirement,
visits: [StadiumVisit],
visitedStadiumIds: Set<String>
) -> (current: Int, total: Int) {
switch requirement {
case .firstVisit:
return (visits.isEmpty ? 0 : 1, 1)
case .visitCount(let count):
return (visitedStadiumIds.count, count)
case .visitCountForSport(let count, let sport):
let sportVisits = visits.filter { $0.sport == sport.rawValue }
let sportStadiums = Set(sportVisits.map { $0.canonicalStadiumId })
return (sportStadiums.count, count)
case .completeDivision(let divisionId):
let stadiumIds = getStadiumIdsForDivision(divisionId)
let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count
return (visited, stadiumIds.count)
case .completeConference(let conferenceId):
let stadiumIds = getStadiumIdsForConference(conferenceId)
let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count
return (visited, stadiumIds.count)
case .completeLeague(let sport):
let stadiumIds = getStadiumIdsForLeague(sport)
let visited = stadiumIds.filter { visitedStadiumIds.contains($0) }.count
return (visited, stadiumIds.count)
case .visitsInDays(let visitCount, _):
// For journey achievements, show total unique stadiums vs required
return (min(visitedStadiumIds.count, visitCount), visitCount)
case .multipleLeagues(let leagueCount):
let leagues = Set(visits.compactMap { Sport(rawValue: $0.sport) })
return (leagues.count, leagueCount)
case .specificStadium(let stadiumId):
return (visitedStadiumIds.contains(stadiumId) ? 1 : 0, 1)
}
}
// MARK: - Contributing Visits
private func getContributingVisitIds(for requirement: AchievementRequirement, visits: [StadiumVisit]) -> [UUID] {
switch requirement {
case .firstVisit:
return visits.first.map { [$0.id] } ?? []
case .visitCount, .visitCountForSport, .multipleLeagues:
// All visits contribute
return visits.map { $0.id }
case .completeDivision(let divisionId):
let stadiumIds = Set(getStadiumIdsForDivision(divisionId))
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
case .completeConference(let conferenceId):
let stadiumIds = Set(getStadiumIdsForConference(conferenceId))
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
case .completeLeague(let sport):
let stadiumIds = Set(getStadiumIdsForLeague(sport))
return visits.filter { stadiumIds.contains($0.canonicalStadiumId) }.map { $0.id }
case .visitsInDays(let requiredVisits, let days):
// Find the qualifying window of visits
let sortedVisits = visits.sorted { $0.visitDate < $1.visitDate }
for i in 0...(sortedVisits.count - requiredVisits) {
let windowStart = sortedVisits[i].visitDate
let windowEnd = sortedVisits[i + requiredVisits - 1].visitDate
let daysDiff = Calendar.current.dateComponents([.day], from: windowStart, to: windowEnd).day ?? Int.max
if daysDiff < days {
return Array(sortedVisits[i..<(i + requiredVisits)]).map { $0.id }
}
}
return []
case .specificStadium(let stadiumId):
return visits.filter { $0.canonicalStadiumId == stadiumId }.map { $0.id }
}
}
// MARK: - Stadium Lookups
private func getStadiumIdsForDivision(_ divisionId: String) -> [String] {
// Get teams in division, then their stadiums
let teams = dataProvider.teams.filter { team in
// Match division by checking team's division assignment
// This would normally come from CanonicalTeam.divisionId
// For now, return based on division structure
return false // Will be populated when division data is linked
}
// For now, return hardcoded counts based on typical division sizes
// This should be replaced with actual team-to-stadium mapping
return []
}
private func getStadiumIdsForConference(_ conferenceId: String) -> [String] {
guard let conference = LeagueStructure.conference(byId: conferenceId) else { return [] }
var stadiumIds: [String] = []
for divisionId in conference.divisionIds {
stadiumIds.append(contentsOf: getStadiumIdsForDivision(divisionId))
}
return stadiumIds
}
private func getStadiumIdsForLeague(_ sport: Sport) -> [String] {
// Get all stadiums for this sport
return dataProvider.stadiums
.filter { stadium in
// Check if stadium hosts teams of this sport
dataProvider.teams.contains { team in
team.stadiumId == stadium.id && team.sport == sport
}
}
.map { "stadium_\(sport.rawValue.lowercased())_\($0.id.uuidString)" }
}
// MARK: - Data Fetching
private func fetchAllVisits() throws -> [StadiumVisit] {
let descriptor = FetchDescriptor<StadiumVisit>(
sortBy: [SortDescriptor(\.visitDate, order: .forward)]
)
return try modelContext.fetch(descriptor)
}
private func fetchEarnedAchievements() throws -> [Achievement] {
let descriptor = FetchDescriptor<Achievement>(
predicate: #Predicate { $0.revokedAt == nil }
)
return try modelContext.fetch(descriptor)
}
}
// MARK: - Achievement Progress
struct AchievementProgress: Identifiable {
let definition: AchievementDefinition
let currentProgress: Int
let totalRequired: Int
let isEarned: Bool
let earnedAt: Date?
var id: String { definition.id }
var progressPercentage: Double {
guard totalRequired > 0 else { return 0 }
return Double(currentProgress) / Double(totalRequired)
}
var progressText: String {
if isEarned {
return "Completed"
}
return "\(currentProgress)/\(totalRequired)"
}
}

View File

@@ -0,0 +1,512 @@
//
// BootstrapService.swift
// SportsTime
//
// Bootstraps canonical data from bundled JSON files into SwiftData.
// Runs once on first launch, then relies on CloudKit for updates.
//
import Foundation
import SwiftData
import CryptoKit
actor BootstrapService {
// MARK: - Errors
enum BootstrapError: Error, LocalizedError {
case bundledResourceNotFound(String)
case jsonDecodingFailed(String, Error)
case saveFailed(Error)
var errorDescription: String? {
switch self {
case .bundledResourceNotFound(let resource):
return "Bundled resource not found: \(resource)"
case .jsonDecodingFailed(let resource, let error):
return "Failed to decode \(resource): \(error.localizedDescription)"
case .saveFailed(let error):
return "Failed to save bootstrap data: \(error.localizedDescription)"
}
}
}
// MARK: - JSON Models (match bundled JSON structure)
private struct JSONStadium: Codable {
let id: String
let name: String
let city: String
let state: String
let latitude: Double
let longitude: Double
let capacity: Int
let sport: String
let team_abbrevs: [String]
let source: String
let year_opened: Int?
}
private struct JSONGame: Codable {
let id: String
let sport: String
let season: String
let date: String
let time: String?
let home_team: String
let away_team: String
let home_team_abbrev: String
let away_team_abbrev: String
let venue: String
let source: String
let is_playoff: Bool
let broadcast: String?
}
private struct JSONLeagueStructure: Codable {
let id: String
let sport: String
let type: String // "conference", "division", "league"
let name: String
let abbreviation: String?
let parent_id: String?
let display_order: Int
}
private struct JSONTeamAlias: Codable {
let id: String
let team_canonical_id: String
let alias_type: String // "abbreviation", "name", "city"
let alias_value: String
let valid_from: String?
let valid_until: String?
}
// MARK: - Public Methods
/// Bootstrap canonical data from bundled JSON if not already done.
/// This is the main entry point called at app launch.
@MainActor
func bootstrapIfNeeded(context: ModelContext) async throws {
let syncState = SyncState.current(in: context)
// Skip if already bootstrapped
guard !syncState.bootstrapCompleted else {
return
}
// Bootstrap in dependency order
try await bootstrapStadiums(context: context)
try await bootstrapLeagueStructure(context: context)
try await bootstrapTeamsAndGames(context: context)
try await bootstrapTeamAliases(context: context)
// Mark bootstrap complete
syncState.bootstrapCompleted = true
syncState.bundledSchemaVersion = SchemaVersion.current
syncState.lastBootstrap = Date()
do {
try context.save()
} catch {
throw BootstrapError.saveFailed(error)
}
}
// MARK: - Bootstrap Steps
@MainActor
private func bootstrapStadiums(context: ModelContext) async throws {
guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else {
throw BootstrapError.bundledResourceNotFound("stadiums.json")
}
let data: Data
let stadiums: [JSONStadium]
do {
data = try Data(contentsOf: url)
stadiums = try JSONDecoder().decode([JSONStadium].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("stadiums.json", error)
}
// Convert and insert
for jsonStadium in stadiums {
let canonical = CanonicalStadium(
canonicalId: jsonStadium.id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.stadiums,
source: .bundled,
name: jsonStadium.name,
city: jsonStadium.city,
state: jsonStadium.state.isEmpty ? stateFromCity(jsonStadium.city) : jsonStadium.state,
latitude: jsonStadium.latitude,
longitude: jsonStadium.longitude,
capacity: jsonStadium.capacity,
yearOpened: jsonStadium.year_opened,
sport: jsonStadium.sport
)
context.insert(canonical)
// Create stadium alias for the current name (lowercase for matching)
let alias = StadiumAlias(
aliasName: jsonStadium.name,
stadiumCanonicalId: jsonStadium.id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.stadiums
)
alias.stadium = canonical
context.insert(alias)
}
}
@MainActor
private func bootstrapLeagueStructure(context: ModelContext) async throws {
// Load league structure if file exists
guard let url = Bundle.main.url(forResource: "league_structure", withExtension: "json") else {
// League structure is optional for MVP - create basic structure from known sports
createDefaultLeagueStructure(context: context)
return
}
let data: Data
let structures: [JSONLeagueStructure]
do {
data = try Data(contentsOf: url)
structures = try JSONDecoder().decode([JSONLeagueStructure].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("league_structure.json", error)
}
for structure in structures {
let structureType: LeagueStructureType
switch structure.type.lowercased() {
case "conference": structureType = .conference
case "division": structureType = .division
case "league": structureType = .league
default: structureType = .division
}
let model = LeagueStructureModel(
id: structure.id,
sport: structure.sport,
structureType: structureType,
name: structure.name,
abbreviation: structure.abbreviation,
parentId: structure.parent_id,
displayOrder: structure.display_order,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.leagueStructure
)
context.insert(model)
}
}
@MainActor
private func bootstrapTeamsAndGames(context: ModelContext) async throws {
guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else {
throw BootstrapError.bundledResourceNotFound("games.json")
}
let data: Data
let games: [JSONGame]
do {
data = try Data(contentsOf: url)
games = try JSONDecoder().decode([JSONGame].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("games.json", error)
}
// Build stadium lookup by venue name for game stadium matching
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
let canonicalStadiums = (try? context.fetch(stadiumDescriptor)) ?? []
var stadiumsByVenue: [String: CanonicalStadium] = [:]
for stadium in canonicalStadiums {
stadiumsByVenue[stadium.name.lowercased()] = stadium
}
// Extract unique teams from games and create CanonicalTeam entries
var teamsCreated: [String: CanonicalTeam] = [:]
var seenGameIds = Set<String>()
for jsonGame in games {
let sport = jsonGame.sport.uppercased()
// Process home team
let homeTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.home_team_abbrev.lowercased())"
if teamsCreated[homeTeamCanonicalId] == nil {
let stadiumCanonicalId = findStadiumCanonicalId(
venue: jsonGame.venue,
sport: sport,
stadiumsByVenue: stadiumsByVenue
)
let team = CanonicalTeam(
canonicalId: homeTeamCanonicalId,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
name: extractTeamName(from: jsonGame.home_team),
abbreviation: jsonGame.home_team_abbrev,
sport: sport,
city: extractCity(from: jsonGame.home_team),
stadiumCanonicalId: stadiumCanonicalId
)
context.insert(team)
teamsCreated[homeTeamCanonicalId] = team
}
// Process away team
let awayTeamCanonicalId = "team_\(sport.lowercased())_\(jsonGame.away_team_abbrev.lowercased())"
if teamsCreated[awayTeamCanonicalId] == nil {
// Away teams might not have a known stadium yet
let team = CanonicalTeam(
canonicalId: awayTeamCanonicalId,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
name: extractTeamName(from: jsonGame.away_team),
abbreviation: jsonGame.away_team_abbrev,
sport: sport,
city: extractCity(from: jsonGame.away_team),
stadiumCanonicalId: "unknown" // Will be filled in when they're home team
)
context.insert(team)
teamsCreated[awayTeamCanonicalId] = team
}
// Deduplicate games by ID
guard !seenGameIds.contains(jsonGame.id) else { continue }
seenGameIds.insert(jsonGame.id)
// Create game
guard let dateTime = parseDateTime(date: jsonGame.date, time: jsonGame.time ?? "7:00p") else {
continue
}
let stadiumCanonicalId = findStadiumCanonicalId(
venue: jsonGame.venue,
sport: sport,
stadiumsByVenue: stadiumsByVenue
)
let game = CanonicalGame(
canonicalId: jsonGame.id,
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games,
source: .bundled,
homeTeamCanonicalId: homeTeamCanonicalId,
awayTeamCanonicalId: awayTeamCanonicalId,
stadiumCanonicalId: stadiumCanonicalId,
dateTime: dateTime,
sport: sport,
season: jsonGame.season,
isPlayoff: jsonGame.is_playoff,
broadcastInfo: jsonGame.broadcast
)
context.insert(game)
}
}
@MainActor
private func bootstrapTeamAliases(context: ModelContext) async throws {
// Team aliases are optional - load if file exists
guard let url = Bundle.main.url(forResource: "team_aliases", withExtension: "json") else {
return
}
let data: Data
let aliases: [JSONTeamAlias]
do {
data = try Data(contentsOf: url)
aliases = try JSONDecoder().decode([JSONTeamAlias].self, from: data)
} catch {
throw BootstrapError.jsonDecodingFailed("team_aliases.json", error)
}
let dateFormatter = ISO8601DateFormatter()
for jsonAlias in aliases {
let aliasType: TeamAliasType
switch jsonAlias.alias_type.lowercased() {
case "abbreviation": aliasType = .abbreviation
case "name": aliasType = .name
case "city": aliasType = .city
default: aliasType = .name
}
let alias = TeamAlias(
id: jsonAlias.id,
teamCanonicalId: jsonAlias.team_canonical_id,
aliasType: aliasType,
aliasValue: jsonAlias.alias_value,
validFrom: jsonAlias.valid_from.flatMap { dateFormatter.date(from: $0) },
validUntil: jsonAlias.valid_until.flatMap { dateFormatter.date(from: $0) },
schemaVersion: SchemaVersion.current,
lastModified: BundledDataTimestamp.games
)
context.insert(alias)
}
}
// MARK: - Helpers
@MainActor
private func createDefaultLeagueStructure(context: ModelContext) {
// Create minimal league structure for supported sports
let timestamp = BundledDataTimestamp.leagueStructure
// MLB
context.insert(LeagueStructureModel(
id: "mlb_league",
sport: "MLB",
structureType: .league,
name: "Major League Baseball",
abbreviation: "MLB",
displayOrder: 0,
schemaVersion: SchemaVersion.current,
lastModified: timestamp
))
// NBA
context.insert(LeagueStructureModel(
id: "nba_league",
sport: "NBA",
structureType: .league,
name: "National Basketball Association",
abbreviation: "NBA",
displayOrder: 0,
schemaVersion: SchemaVersion.current,
lastModified: timestamp
))
// NHL
context.insert(LeagueStructureModel(
id: "nhl_league",
sport: "NHL",
structureType: .league,
name: "National Hockey League",
abbreviation: "NHL",
displayOrder: 0,
schemaVersion: SchemaVersion.current,
lastModified: timestamp
))
}
// Venue name aliases for stadiums that changed names
private static let venueAliases: [String: String] = [
"daikin park": "minute maid park",
"rate field": "guaranteed rate field",
"george m. steinbrenner field": "tropicana field",
"loandepot park": "loandepot park",
]
nonisolated private func findStadiumCanonicalId(
venue: String,
sport: String,
stadiumsByVenue: [String: CanonicalStadium]
) -> String {
var venueLower = venue.lowercased()
// Check for known aliases
if let aliasedName = Self.venueAliases[venueLower] {
venueLower = aliasedName
}
// Try exact match
if let stadium = stadiumsByVenue[venueLower] {
return stadium.canonicalId
}
// Try partial match
for (name, stadium) in stadiumsByVenue {
if name.contains(venueLower) || venueLower.contains(name) {
return stadium.canonicalId
}
}
// Generate deterministic ID for unknown venues
return "venue_unknown_\(venue.lowercased().replacingOccurrences(of: " ", with: "_"))"
}
nonisolated private func parseDateTime(date: String, time: String) -> Date? {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
// Parse date
formatter.dateFormat = "yyyy-MM-dd"
guard let dateOnly = formatter.date(from: date) else { return nil }
// Parse time (e.g., "7:30p", "10:00p", "1:05p")
var hour = 12
var minute = 0
let cleanTime = time.lowercased().replacingOccurrences(of: " ", with: "")
let isPM = cleanTime.contains("p")
let timeWithoutAMPM = cleanTime.replacingOccurrences(of: "p", with: "").replacingOccurrences(of: "a", with: "")
let components = timeWithoutAMPM.split(separator: ":")
if !components.isEmpty, let h = Int(components[0]) {
hour = h
if isPM && hour != 12 {
hour += 12
} else if !isPM && hour == 12 {
hour = 0
}
}
if components.count > 1, let m = Int(components[1]) {
minute = m
}
return Calendar.current.date(bySettingHour: hour, minute: minute, second: 0, of: dateOnly)
}
nonisolated private func extractTeamName(from fullName: String) -> String {
// "Boston Celtics" -> "Celtics"
let parts = fullName.split(separator: " ")
if parts.count > 1 {
return parts.dropFirst().joined(separator: " ")
}
return fullName
}
nonisolated private func extractCity(from fullName: String) -> String {
// "Boston Celtics" -> "Boston"
// "New York Knicks" -> "New York"
let knownCities = [
"New York", "Los Angeles", "San Francisco", "San Diego", "San Antonio",
"New Orleans", "Oklahoma City", "Salt Lake City", "Kansas City",
"St. Louis", "St Louis"
]
for city in knownCities {
if fullName.hasPrefix(city) {
return city
}
}
// Default: first word
return String(fullName.split(separator: " ").first ?? Substring(fullName))
}
nonisolated private func stateFromCity(_ city: String) -> String {
let cityToState: [String: String] = [
"Atlanta": "GA", "Boston": "MA", "Brooklyn": "NY", "Charlotte": "NC",
"Chicago": "IL", "Cleveland": "OH", "Dallas": "TX", "Denver": "CO",
"Detroit": "MI", "Houston": "TX", "Indianapolis": "IN", "Los Angeles": "CA",
"Memphis": "TN", "Miami": "FL", "Milwaukee": "WI", "Minneapolis": "MN",
"New Orleans": "LA", "New York": "NY", "Oklahoma City": "OK", "Orlando": "FL",
"Philadelphia": "PA", "Phoenix": "AZ", "Portland": "OR", "Sacramento": "CA",
"San Antonio": "TX", "San Francisco": "CA", "Seattle": "WA", "Toronto": "ON",
"Washington": "DC", "Las Vegas": "NV", "Tampa": "FL", "Pittsburgh": "PA",
"Baltimore": "MD", "Cincinnati": "OH", "St. Louis": "MO", "Kansas City": "MO",
"Arlington": "TX", "Anaheim": "CA", "Oakland": "CA", "San Diego": "CA",
"Tampa Bay": "FL", "St Petersburg": "FL", "Salt Lake City": "UT"
]
return cityToState[city] ?? ""
}
}

View File

@@ -0,0 +1,234 @@
//
// CanonicalDataProvider.swift
// SportsTime
//
// DataProvider implementation that reads from SwiftData canonical models.
// This is the primary data source after bootstrap completes.
//
import Foundation
import SwiftData
actor CanonicalDataProvider: DataProvider {
// MARK: - Properties
private let modelContainer: ModelContainer
// Caches for converted domain objects (rebuilt on first access)
private var cachedTeams: [Team]?
private var cachedStadiums: [Stadium]?
private var teamsByCanonicalId: [String: Team] = [:]
private var stadiumsByCanonicalId: [String: Stadium] = [:]
private var teamUUIDByCanonicalId: [String: UUID] = [:]
private var stadiumUUIDByCanonicalId: [String: UUID] = [:]
// MARK: - Initialization
init(modelContainer: ModelContainer) {
self.modelContainer = modelContainer
}
// MARK: - DataProvider Protocol
func fetchTeams(for sport: Sport) async throws -> [Team] {
try await loadCachesIfNeeded()
return cachedTeams?.filter { $0.sport == sport } ?? []
}
func fetchAllTeams() async throws -> [Team] {
try await loadCachesIfNeeded()
return cachedTeams ?? []
}
func fetchStadiums() async throws -> [Stadium] {
try await loadCachesIfNeeded()
return cachedStadiums ?? []
}
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
try await loadCachesIfNeeded()
let context = ModelContext(modelContainer)
// Fetch canonical games within date range
let sportStrings = sports.map { $0.rawValue }
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
sportStrings.contains(game.sport) &&
game.dateTime >= startDate &&
game.dateTime <= endDate &&
game.deprecatedAt == nil
},
sortBy: [SortDescriptor(\.dateTime)]
)
let canonicalGames = try context.fetch(descriptor)
// Convert to domain models
return canonicalGames.compactMap { canonical -> Game? in
guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId],
let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId],
let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
return nil
}
return Game(
id: canonical.uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
dateTime: canonical.dateTime,
sport: canonical.sportEnum ?? .mlb,
season: canonical.season,
isPlayoff: canonical.isPlayoff,
broadcastInfo: canonical.broadcastInfo
)
}
}
func fetchGame(by id: UUID) async throws -> Game? {
try await loadCachesIfNeeded()
let context = ModelContext(modelContainer)
// Search by UUID
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate<CanonicalGame> { game in
game.uuid == id && game.deprecatedAt == nil
}
)
guard let canonical = try context.fetch(descriptor).first else {
return nil
}
guard let homeTeamUUID = teamUUIDByCanonicalId[canonical.homeTeamCanonicalId],
let awayTeamUUID = teamUUIDByCanonicalId[canonical.awayTeamCanonicalId],
let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
return nil
}
return Game(
id: canonical.uuid,
homeTeamId: homeTeamUUID,
awayTeamId: awayTeamUUID,
stadiumId: stadiumUUID,
dateTime: canonical.dateTime,
sport: canonical.sportEnum ?? .mlb,
season: canonical.season,
isPlayoff: canonical.isPlayoff,
broadcastInfo: canonical.broadcastInfo
)
}
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
try await loadCachesIfNeeded()
let games = try await fetchGames(sports: sports, startDate: startDate, endDate: endDate)
let teamsById = Dictionary(uniqueKeysWithValues: (cachedTeams ?? []).map { ($0.id, $0) })
let stadiumsById = Dictionary(uniqueKeysWithValues: (cachedStadiums ?? []).map { ($0.id, $0) })
return games.compactMap { game in
guard let homeTeam = teamsById[game.homeTeamId],
let awayTeam = teamsById[game.awayTeamId],
let stadium = stadiumsById[game.stadiumId] else {
return nil
}
return RichGame(game: game, homeTeam: homeTeam, awayTeam: awayTeam, stadium: stadium)
}
}
// MARK: - Additional Queries
/// Fetch stadium by canonical ID (useful for visit tracking)
func fetchStadium(byCanonicalId canonicalId: String) async throws -> Stadium? {
try await loadCachesIfNeeded()
return stadiumsByCanonicalId[canonicalId]
}
/// Fetch team by canonical ID
func fetchTeam(byCanonicalId canonicalId: String) async throws -> Team? {
try await loadCachesIfNeeded()
return teamsByCanonicalId[canonicalId]
}
/// Find stadium by name (matches aliases)
func findStadium(byName name: String) async throws -> Stadium? {
let context = ModelContext(modelContainer)
// Precompute lowercased name outside the predicate
let lowercasedName = name.lowercased()
// First try exact alias match
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { alias in
alias.aliasName == lowercasedName
}
)
if let alias = try context.fetch(aliasDescriptor).first,
let stadiumCanonicalId = Optional(alias.stadiumCanonicalId) {
return try await fetchStadium(byCanonicalId: stadiumCanonicalId)
}
return nil
}
/// Invalidate caches (call after sync completes)
func invalidateCaches() {
cachedTeams = nil
cachedStadiums = nil
teamsByCanonicalId.removeAll()
stadiumsByCanonicalId.removeAll()
teamUUIDByCanonicalId.removeAll()
stadiumUUIDByCanonicalId.removeAll()
}
// MARK: - Private Helpers
private func loadCachesIfNeeded() async throws {
guard cachedTeams == nil else { return }
let context = ModelContext(modelContainer)
// Load stadiums
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.deprecatedAt == nil
}
)
let canonicalStadiums = try context.fetch(stadiumDescriptor)
cachedStadiums = canonicalStadiums.map { canonical in
let stadium = canonical.toDomain()
stadiumsByCanonicalId[canonical.canonicalId] = stadium
stadiumUUIDByCanonicalId[canonical.canonicalId] = stadium.id
return stadium
}
// Load teams
let teamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate<CanonicalTeam> { team in
team.deprecatedAt == nil
}
)
let canonicalTeams = try context.fetch(teamDescriptor)
cachedTeams = canonicalTeams.compactMap { canonical -> Team? in
guard let stadiumUUID = stadiumUUIDByCanonicalId[canonical.stadiumCanonicalId] else {
// Generate a placeholder UUID for teams without known stadiums
let placeholderUUID = CanonicalStadium.deterministicUUID(from: canonical.stadiumCanonicalId)
let team = canonical.toDomain(stadiumUUID: placeholderUUID)
teamsByCanonicalId[canonical.canonicalId] = team
teamUUIDByCanonicalId[canonical.canonicalId] = team.id
return team
}
let team = canonical.toDomain(stadiumUUID: stadiumUUID)
teamsByCanonicalId[canonical.canonicalId] = team
teamUUIDByCanonicalId[canonical.canonicalId] = team.id
return team
}
}
}

View File

@@ -0,0 +1,634 @@
//
// CanonicalSyncService.swift
// SportsTime
//
// Orchestrates syncing canonical data from CloudKit into SwiftData.
// Uses date-based delta sync for public database efficiency.
//
import Foundation
import SwiftData
import CloudKit
actor CanonicalSyncService {
// MARK: - Errors
enum SyncError: Error, LocalizedError {
case cloudKitUnavailable
case syncAlreadyInProgress
case saveFailed(Error)
case schemaVersionTooNew(Int)
var errorDescription: String? {
switch self {
case .cloudKitUnavailable:
return "CloudKit is not available. Check your internet connection and iCloud settings."
case .syncAlreadyInProgress:
return "A sync operation is already in progress."
case .saveFailed(let error):
return "Failed to save synced data: \(error.localizedDescription)"
case .schemaVersionTooNew(let version):
return "Data requires app version supporting schema \(version). Please update the app."
}
}
}
// MARK: - Sync Result
struct SyncResult {
let stadiumsUpdated: Int
let teamsUpdated: Int
let gamesUpdated: Int
let leagueStructuresUpdated: Int
let teamAliasesUpdated: Int
let skippedIncompatible: Int
let skippedOlder: Int
let duration: TimeInterval
var totalUpdated: Int {
stadiumsUpdated + teamsUpdated + gamesUpdated + leagueStructuresUpdated + teamAliasesUpdated
}
var isEmpty: Bool { totalUpdated == 0 }
}
// MARK: - Properties
private let cloudKitService: CloudKitService
// MARK: - Initialization
init(cloudKitService: CloudKitService = .shared) {
self.cloudKitService = cloudKitService
}
// MARK: - Public Sync Methods
/// Perform a full sync of all canonical data types.
/// This is the main entry point for background sync.
@MainActor
func syncAll(context: ModelContext) async throws -> SyncResult {
let startTime = Date()
let syncState = SyncState.current(in: context)
// Prevent concurrent syncs
guard !syncState.syncInProgress else {
throw SyncError.syncAlreadyInProgress
}
// Check if sync is enabled
guard syncState.syncEnabled else {
return SyncResult(
stadiumsUpdated: 0, teamsUpdated: 0, gamesUpdated: 0,
leagueStructuresUpdated: 0, teamAliasesUpdated: 0,
skippedIncompatible: 0, skippedOlder: 0,
duration: 0
)
}
// Check CloudKit availability
guard await cloudKitService.isAvailable() else {
throw SyncError.cloudKitUnavailable
}
// Mark sync in progress
syncState.syncInProgress = true
syncState.lastSyncAttempt = Date()
var totalStadiums = 0
var totalTeams = 0
var totalGames = 0
var totalLeagueStructures = 0
var totalTeamAliases = 0
var totalSkippedIncompatible = 0
var totalSkippedOlder = 0
do {
// Sync in dependency order
let (stadiums, skipIncompat1, skipOlder1) = try await syncStadiums(
context: context,
since: syncState.lastSuccessfulSync
)
totalStadiums = stadiums
totalSkippedIncompatible += skipIncompat1
totalSkippedOlder += skipOlder1
let (leagueStructures, skipIncompat2, skipOlder2) = try await syncLeagueStructure(
context: context,
since: syncState.lastSuccessfulSync
)
totalLeagueStructures = leagueStructures
totalSkippedIncompatible += skipIncompat2
totalSkippedOlder += skipOlder2
let (teams, skipIncompat3, skipOlder3) = try await syncTeams(
context: context,
since: syncState.lastSuccessfulSync
)
totalTeams = teams
totalSkippedIncompatible += skipIncompat3
totalSkippedOlder += skipOlder3
let (teamAliases, skipIncompat4, skipOlder4) = try await syncTeamAliases(
context: context,
since: syncState.lastSuccessfulSync
)
totalTeamAliases = teamAliases
totalSkippedIncompatible += skipIncompat4
totalSkippedOlder += skipOlder4
let (games, skipIncompat5, skipOlder5) = try await syncGames(
context: context,
since: syncState.lastSuccessfulSync
)
totalGames = games
totalSkippedIncompatible += skipIncompat5
totalSkippedOlder += skipOlder5
// Mark sync successful
syncState.syncInProgress = false
syncState.lastSuccessfulSync = Date()
syncState.lastSyncError = nil
syncState.consecutiveFailures = 0
try context.save()
} catch {
// Mark sync failed
syncState.syncInProgress = false
syncState.lastSyncError = error.localizedDescription
syncState.consecutiveFailures += 1
// Pause sync after too many failures
if syncState.consecutiveFailures >= 5 {
syncState.syncEnabled = false
syncState.syncPausedReason = "Too many consecutive failures. Sync paused."
}
try? context.save()
throw error
}
return SyncResult(
stadiumsUpdated: totalStadiums,
teamsUpdated: totalTeams,
gamesUpdated: totalGames,
leagueStructuresUpdated: totalLeagueStructures,
teamAliasesUpdated: totalTeamAliases,
skippedIncompatible: totalSkippedIncompatible,
skippedOlder: totalSkippedOlder,
duration: Date().timeIntervalSince(startTime)
)
}
/// Re-enable sync after it was paused due to failures.
@MainActor
func resumeSync(context: ModelContext) {
let syncState = SyncState.current(in: context)
syncState.syncEnabled = true
syncState.syncPausedReason = nil
syncState.consecutiveFailures = 0
try? context.save()
}
// MARK: - Individual Sync Methods
@MainActor
private func syncStadiums(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteStadiums = try await cloudKitService.fetchStadiums()
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteStadium in remoteStadiums {
// For now, fetch full list and merge - CloudKit public DB doesn't have delta sync
// In future, could add lastModified filtering on CloudKit query
let canonicalId = "stadium_\(remoteStadium.sport.rawValue.lowercased())_\(remoteStadium.id.uuidString.prefix(8))"
let result = try mergeStadium(
remoteStadium,
canonicalId: canonicalId,
context: context
)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncTeams(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Fetch teams for all sports
var allTeams: [Team] = []
for sport in Sport.allCases {
let teams = try await cloudKitService.fetchTeams(for: sport)
allTeams.append(contentsOf: teams)
}
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteTeam in allTeams {
let canonicalId = "team_\(remoteTeam.sport.rawValue.lowercased())_\(remoteTeam.abbreviation.lowercased())"
let result = try mergeTeam(
remoteTeam,
canonicalId: canonicalId,
context: context
)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncGames(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
// Fetch games for the next 6 months from all sports
let startDate = lastSync ?? Date()
let endDate = Calendar.current.date(byAdding: .month, value: 6, to: Date()) ?? Date()
let remoteGames = try await cloudKitService.fetchGames(
sports: Set(Sport.allCases),
startDate: startDate,
endDate: endDate
)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteGame in remoteGames {
let result = try mergeGame(
remoteGame,
canonicalId: remoteGame.id.uuidString,
context: context
)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncLeagueStructure(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteStructures = try await cloudKitService.fetchLeagueStructureChanges(since: lastSync)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteStructure in remoteStructures {
let result = try mergeLeagueStructure(remoteStructure, context: context)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
@MainActor
private func syncTeamAliases(
context: ModelContext,
since lastSync: Date?
) async throws -> (updated: Int, skippedIncompatible: Int, skippedOlder: Int) {
let remoteAliases = try await cloudKitService.fetchTeamAliasChanges(since: lastSync)
var updated = 0
var skippedIncompatible = 0
var skippedOlder = 0
for remoteAlias in remoteAliases {
let result = try mergeTeamAlias(remoteAlias, context: context)
switch result {
case .applied: updated += 1
case .skippedIncompatible: skippedIncompatible += 1
case .skippedOlder: skippedOlder += 1
}
}
return (updated, skippedIncompatible, skippedOlder)
}
// MARK: - Merge Logic
private enum MergeResult {
case applied
case skippedIncompatible
case skippedOlder
}
@MainActor
private func mergeStadium(
_ remote: Stadium,
canonicalId: String,
context: ModelContext
) throws -> MergeResult {
// Look up existing
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
let existing = try context.fetch(descriptor).first
if let existing = existing {
// Preserve user fields
let savedNickname = existing.userNickname
let savedNotes = existing.userNotes
let savedFavorite = existing.isFavorite
// Update system fields
existing.name = remote.name
existing.city = remote.city
existing.state = remote.state
existing.latitude = remote.latitude
existing.longitude = remote.longitude
existing.capacity = remote.capacity
existing.yearOpened = remote.yearOpened
existing.imageURL = remote.imageURL?.absoluteString
existing.sport = remote.sport.rawValue
existing.source = .cloudKit
existing.lastModified = Date()
// Restore user fields
existing.userNickname = savedNickname
existing.userNotes = savedNotes
existing.isFavorite = savedFavorite
return .applied
} else {
// Insert new
let canonical = CanonicalStadium(
canonicalId: canonicalId,
uuid: remote.id,
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
name: remote.name,
city: remote.city,
state: remote.state,
latitude: remote.latitude,
longitude: remote.longitude,
capacity: remote.capacity,
yearOpened: remote.yearOpened,
imageURL: remote.imageURL?.absoluteString,
sport: remote.sport.rawValue
)
context.insert(canonical)
return .applied
}
}
@MainActor
private func mergeTeam(
_ remote: Team,
canonicalId: String,
context: ModelContext
) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
let existing = try context.fetch(descriptor).first
// Find stadium canonical ID
let remoteStadiumId = remote.stadiumId
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.uuid == remoteStadiumId }
)
let stadium = try context.fetch(stadiumDescriptor).first
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
if let existing = existing {
// Preserve user fields
let savedNickname = existing.userNickname
let savedFavorite = existing.isFavorite
// Update system fields
existing.name = remote.name
existing.abbreviation = remote.abbreviation
existing.sport = remote.sport.rawValue
existing.city = remote.city
existing.stadiumCanonicalId = stadiumCanonicalId
existing.logoURL = remote.logoURL?.absoluteString
existing.primaryColor = remote.primaryColor
existing.secondaryColor = remote.secondaryColor
existing.source = .cloudKit
existing.lastModified = Date()
// Restore user fields
existing.userNickname = savedNickname
existing.isFavorite = savedFavorite
return .applied
} else {
let canonical = CanonicalTeam(
canonicalId: canonicalId,
uuid: remote.id,
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
name: remote.name,
abbreviation: remote.abbreviation,
sport: remote.sport.rawValue,
city: remote.city,
stadiumCanonicalId: stadiumCanonicalId,
logoURL: remote.logoURL?.absoluteString,
primaryColor: remote.primaryColor,
secondaryColor: remote.secondaryColor
)
context.insert(canonical)
return .applied
}
}
@MainActor
private func mergeGame(
_ remote: Game,
canonicalId: String,
context: ModelContext
) throws -> MergeResult {
let descriptor = FetchDescriptor<CanonicalGame>(
predicate: #Predicate { $0.canonicalId == canonicalId }
)
let existing = try context.fetch(descriptor).first
// Look up canonical IDs for teams and stadium
let remoteHomeTeamId = remote.homeTeamId
let remoteAwayTeamId = remote.awayTeamId
let remoteStadiumId = remote.stadiumId
let homeTeamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.uuid == remoteHomeTeamId }
)
let awayTeamDescriptor = FetchDescriptor<CanonicalTeam>(
predicate: #Predicate { $0.uuid == remoteAwayTeamId }
)
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate { $0.uuid == remoteStadiumId }
)
let homeTeam = try context.fetch(homeTeamDescriptor).first
let awayTeam = try context.fetch(awayTeamDescriptor).first
let stadium = try context.fetch(stadiumDescriptor).first
let homeTeamCanonicalId = homeTeam?.canonicalId ?? "unknown"
let awayTeamCanonicalId = awayTeam?.canonicalId ?? "unknown"
let stadiumCanonicalId = stadium?.canonicalId ?? "unknown"
if let existing = existing {
// Preserve user fields
let savedAttending = existing.userAttending
let savedNotes = existing.userNotes
// Update system fields
existing.homeTeamCanonicalId = homeTeamCanonicalId
existing.awayTeamCanonicalId = awayTeamCanonicalId
existing.stadiumCanonicalId = stadiumCanonicalId
existing.dateTime = remote.dateTime
existing.sport = remote.sport.rawValue
existing.season = remote.season
existing.isPlayoff = remote.isPlayoff
existing.broadcastInfo = remote.broadcastInfo
existing.source = .cloudKit
existing.lastModified = Date()
// Restore user fields
existing.userAttending = savedAttending
existing.userNotes = savedNotes
return .applied
} else {
let canonical = CanonicalGame(
canonicalId: canonicalId,
uuid: remote.id,
schemaVersion: SchemaVersion.current,
lastModified: Date(),
source: .cloudKit,
homeTeamCanonicalId: homeTeamCanonicalId,
awayTeamCanonicalId: awayTeamCanonicalId,
stadiumCanonicalId: stadiumCanonicalId,
dateTime: remote.dateTime,
sport: remote.sport.rawValue,
season: remote.season,
isPlayoff: remote.isPlayoff,
broadcastInfo: remote.broadcastInfo
)
context.insert(canonical)
return .applied
}
}
@MainActor
private func mergeLeagueStructure(
_ remote: LeagueStructureModel,
context: ModelContext
) throws -> MergeResult {
// Schema version check
guard remote.schemaVersion <= SchemaVersion.current else {
return .skippedIncompatible
}
let remoteId = remote.id
let descriptor = FetchDescriptor<LeagueStructureModel>(
predicate: #Predicate { $0.id == remoteId }
)
let existing = try context.fetch(descriptor).first
if let existing = existing {
// lastModified check
guard remote.lastModified > existing.lastModified else {
return .skippedOlder
}
// Update all fields (no user fields on LeagueStructure)
existing.sport = remote.sport
existing.structureTypeRaw = remote.structureTypeRaw
existing.name = remote.name
existing.abbreviation = remote.abbreviation
existing.parentId = remote.parentId
existing.displayOrder = remote.displayOrder
existing.schemaVersion = remote.schemaVersion
existing.lastModified = remote.lastModified
return .applied
} else {
// Insert new
context.insert(remote)
return .applied
}
}
@MainActor
private func mergeTeamAlias(
_ remote: TeamAlias,
context: ModelContext
) throws -> MergeResult {
// Schema version check
guard remote.schemaVersion <= SchemaVersion.current else {
return .skippedIncompatible
}
let remoteId = remote.id
let descriptor = FetchDescriptor<TeamAlias>(
predicate: #Predicate { $0.id == remoteId }
)
let existing = try context.fetch(descriptor).first
if let existing = existing {
// lastModified check
guard remote.lastModified > existing.lastModified else {
return .skippedOlder
}
// Update all fields (no user fields on TeamAlias)
existing.teamCanonicalId = remote.teamCanonicalId
existing.aliasTypeRaw = remote.aliasTypeRaw
existing.aliasValue = remote.aliasValue
existing.validFrom = remote.validFrom
existing.validUntil = remote.validUntil
existing.schemaVersion = remote.schemaVersion
existing.lastModified = remote.lastModified
return .applied
} else {
// Insert new
context.insert(remote)
return .applied
}
}
}

View File

@@ -189,6 +189,87 @@ actor CloudKitService {
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
}
// MARK: - League Structure & Team Aliases
func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] {
let predicate: NSPredicate
if let sport = sport {
predicate = NSPredicate(format: "sport == %@", sport.rawValue)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.displayOrderKey, ascending: true)]
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKLeagueStructure(record: record).toModel()
}
}
func fetchTeamAliases(for teamCanonicalId: String? = nil) async throws -> [TeamAlias] {
let predicate: NSPredicate
if let teamId = teamCanonicalId {
predicate = NSPredicate(format: "teamCanonicalId == %@", teamId)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKTeamAlias(record: record).toModel()
}
}
// MARK: - Delta Sync (Date-Based for Public Database)
/// Fetch league structure records modified after the given date
func fetchLeagueStructureChanges(since lastSync: Date?) async throws -> [LeagueStructureModel] {
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)]
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKLeagueStructure(record: record).toModel()
}
}
/// Fetch team alias records modified after the given date
func fetchTeamAliasChanges(since lastSync: Date?) async throws -> [TeamAlias] {
let predicate: NSPredicate
if let lastSync = lastSync {
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
} else {
predicate = NSPredicate(value: true)
}
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)]
let (results, _) = try await publicDatabase.records(matching: query)
return results.compactMap { result in
guard case .success(let record) = result.1 else { return nil }
return CKTeamAlias(record: record).toModel()
}
}
// MARK: - Sync Status
func checkAccountStatus() async -> CKAccountStatus {
@@ -199,7 +280,7 @@ actor CloudKitService {
}
}
// MARK: - Subscription (for schedule updates)
// MARK: - Subscriptions
func subscribeToScheduleUpdates() async throws {
let subscription = CKQuerySubscription(
@@ -215,4 +296,41 @@ actor CloudKitService {
try await publicDatabase.save(subscription)
}
func subscribeToLeagueStructureUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.leagueStructure,
predicate: NSPredicate(value: true),
subscriptionID: "league-structure-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
}
func subscribeToTeamAliasUpdates() async throws {
let subscription = CKQuerySubscription(
recordType: CKRecordType.teamAlias,
predicate: NSPredicate(value: true),
subscriptionID: "team-alias-updates",
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
)
let notification = CKSubscription.NotificationInfo()
notification.shouldSendContentAvailable = true
subscription.notificationInfo = notification
try await publicDatabase.save(subscription)
}
/// Subscribe to all canonical data updates
func subscribeToAllUpdates() async throws {
try await subscribeToScheduleUpdates()
try await subscribeToLeagueStructureUpdates()
try await subscribeToTeamAliasUpdates()
}
}

View File

@@ -0,0 +1,298 @@
//
// FreeScoreAPI.swift
// SportsTime
//
// Multi-provider score resolution facade using FREE data sources only.
//
import Foundation
// MARK: - Provider Reliability
enum ProviderReliability: String, Sendable {
case official // MLB Stats, NHL Stats - stable, documented
case unofficial // ESPN API - works but may break
case scraped // Sports-Reference - HTML parsing, fragile
}
// MARK: - Historical Game Query
struct HistoricalGameQuery: Sendable {
let sport: Sport
let date: Date
let homeTeamAbbrev: String?
let awayTeamAbbrev: String?
let stadiumCanonicalId: String?
init(
sport: Sport,
date: Date,
homeTeamAbbrev: String? = nil,
awayTeamAbbrev: String? = nil,
stadiumCanonicalId: String? = nil
) {
self.sport = sport
self.date = date
self.homeTeamAbbrev = homeTeamAbbrev
self.awayTeamAbbrev = awayTeamAbbrev
self.stadiumCanonicalId = stadiumCanonicalId
}
/// Normalized date string for matching
var normalizedDateString: String {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
formatter.timeZone = TimeZone(identifier: "America/New_York")
return formatter.string(from: date)
}
}
// MARK: - Historical Game Result
struct HistoricalGameResult: Sendable {
let sport: Sport
let gameDate: Date
let homeTeamAbbrev: String
let awayTeamAbbrev: String
let homeTeamName: String
let awayTeamName: String
let homeScore: Int?
let awayScore: Int?
let source: ScoreSource
let providerName: String
var scoreString: String? {
guard let home = homeScore, let away = awayScore else { return nil }
return "\(away)-\(home)"
}
var hasScore: Bool {
homeScore != nil && awayScore != nil
}
}
// MARK: - Score Resolution Result
enum ScoreResolutionResult: Sendable {
case resolved(HistoricalGameResult)
case pending // Background retry queued
case requiresUserInput(reason: String) // All tiers failed
case notFound(reason: String) // No game matched query
var isResolved: Bool {
if case .resolved = self { return true }
return false
}
var result: HistoricalGameResult? {
if case .resolved(let result) = self { return result }
return nil
}
}
// MARK: - Score API Provider Protocol
protocol ScoreAPIProvider: Sendable {
var name: String { get }
var supportedSports: Set<Sport> { get }
var reliability: ProviderReliability { get }
var rateLimitKey: String { get }
func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult?
}
// MARK: - Provider Errors
enum ScoreProviderError: Error, LocalizedError, Sendable {
case networkError(underlying: String)
case rateLimited
case parseError(message: String)
case gameNotFound
case unsupportedSport(Sport)
case providerUnavailable(reason: String)
var errorDescription: String? {
switch self {
case .networkError(let underlying):
return "Network error: \(underlying)"
case .rateLimited:
return "Rate limited by provider"
case .parseError(let message):
return "Failed to parse response: \(message)"
case .gameNotFound:
return "Game not found"
case .unsupportedSport(let sport):
return "\(sport.rawValue) not supported by this provider"
case .providerUnavailable(let reason):
return "Provider unavailable: \(reason)"
}
}
}
// MARK: - Free Score API Orchestrator
@MainActor
final class FreeScoreAPI {
// MARK: - Properties
static let shared = FreeScoreAPI()
private var providers: [ScoreAPIProvider] = []
private var disabledProviders: [String: Date] = [:] // provider disabled until
private var failureCounts: [String: Int] = [:]
// Failure thresholds
private let officialFailureThreshold = Int.max // Never auto-disable
private let unofficialFailureThreshold = 3
private let scrapedFailureThreshold = 2
private let disableDuration: TimeInterval = 24 * 60 * 60 // 24 hours
private let failureWindowDuration: TimeInterval = 60 * 60 // 1 hour
private let rateLimiter = RateLimiter.shared
// MARK: - Initialization
private init() {
// Register providers in priority order
registerDefaultProviders()
}
private func registerDefaultProviders() {
// Official APIs first (most reliable)
providers.append(MLBStatsProvider())
providers.append(NHLStatsProvider())
providers.append(NBAStatsProvider())
// Note: ESPN provider could be added here as unofficial fallback
// Note: Sports-Reference scraper could be added as last resort
}
// MARK: - Public API
/// Register a custom provider
func registerProvider(_ provider: ScoreAPIProvider) {
providers.append(provider)
}
/// Resolve score for a game query
/// Tries each provider in order: official > unofficial > scraped
func resolveScore(query: HistoricalGameQuery) async -> ScoreResolutionResult {
// Filter providers that support this sport
let eligibleProviders = providers.filter {
$0.supportedSports.contains(query.sport) && !isDisabled($0)
}
guard !eligibleProviders.isEmpty else {
return .requiresUserInput(reason: "No providers available for \(query.sport.rawValue)")
}
// Sort by reliability (official first)
let sortedProviders = eligibleProviders.sorted { p1, p2 in
reliabilityOrder(p1.reliability) < reliabilityOrder(p2.reliability)
}
// Try each provider in order
for provider in sortedProviders {
do {
// Wait for rate limit
await rateLimiter.waitIfNeeded(for: provider.rateLimitKey)
// Attempt fetch
if let result = try await provider.fetchGame(query: query) {
// Success - reset failure count
resetFailureCount(for: provider)
return .resolved(result)
}
} catch {
// Record failure
recordFailure(for: provider, error: error)
// Continue to next provider if this one failed
continue
}
}
// All providers failed or returned nil
return .notFound(reason: "Game not found in any provider for \(query.sport.rawValue) on \(query.normalizedDateString)")
}
/// Check if a provider is available
func isProviderAvailable(_ providerName: String) -> Bool {
guard let provider = providers.first(where: { $0.name == providerName }) else {
return false
}
return !isDisabled(provider)
}
/// Get list of available providers for a sport
func availableProviders(for sport: Sport) -> [String] {
providers
.filter { $0.supportedSports.contains(sport) && !isDisabled($0) }
.map { $0.name }
}
/// Manually re-enable a disabled provider
func enableProvider(_ providerName: String) {
disabledProviders.removeValue(forKey: providerName)
failureCounts.removeValue(forKey: providerName)
}
/// Manually disable a provider
func disableProvider(_ providerName: String, until date: Date) {
disabledProviders[providerName] = date
}
// MARK: - Provider Management
private func isDisabled(_ provider: ScoreAPIProvider) -> Bool {
guard let disabledUntil = disabledProviders[provider.name] else {
return false
}
// Check if disable period has expired
if Date() > disabledUntil {
disabledProviders.removeValue(forKey: provider.name)
return false
}
return true
}
private func recordFailure(for provider: ScoreAPIProvider, error: Error) {
let count = (failureCounts[provider.name] ?? 0) + 1
failureCounts[provider.name] = count
// Check if should auto-disable
let threshold = failureThreshold(for: provider.reliability)
if count >= threshold {
let disableUntil = Date().addingTimeInterval(disableDuration)
disabledProviders[provider.name] = disableUntil
failureCounts.removeValue(forKey: provider.name)
}
}
private func resetFailureCount(for provider: ScoreAPIProvider) {
failureCounts.removeValue(forKey: provider.name)
}
private func failureThreshold(for reliability: ProviderReliability) -> Int {
switch reliability {
case .official:
return officialFailureThreshold
case .unofficial:
return unofficialFailureThreshold
case .scraped:
return scrapedFailureThreshold
}
}
private func reliabilityOrder(_ reliability: ProviderReliability) -> Int {
switch reliability {
case .official: return 0
case .unofficial: return 1
case .scraped: return 2
}
}
}

View File

@@ -0,0 +1,324 @@
//
// GameMatcher.swift
// SportsTime
//
// Deterministic game matching from photo metadata.
//
import Foundation
import CoreLocation
// MARK: - No Match Reason
enum NoMatchReason: Sendable {
case noStadiumNearby
case noGamesOnDate
case metadataMissing(MetadataMissingReason)
enum MetadataMissingReason: Sendable {
case noLocation
case noDate
case noBoth
}
var description: String {
switch self {
case .noStadiumNearby:
return "No stadium found nearby"
case .noGamesOnDate:
return "No games found on this date"
case .metadataMissing(let reason):
switch reason {
case .noLocation:
return "Photo has no location data"
case .noDate:
return "Photo has no date information"
case .noBoth:
return "Photo has no location or date data"
}
}
}
}
// MARK: - Game Match Result
struct GameMatchCandidate: Identifiable, Sendable {
let id: UUID
let game: Game
let stadium: Stadium
let homeTeam: Team
let awayTeam: Team
let confidence: PhotoMatchConfidence
init(game: Game, stadium: Stadium, homeTeam: Team, awayTeam: Team, confidence: PhotoMatchConfidence) {
self.id = game.id
self.game = game
self.stadium = stadium
self.homeTeam = homeTeam
self.awayTeam = awayTeam
self.confidence = confidence
}
var matchupDescription: String {
"\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)"
}
var fullMatchupDescription: String {
"\(awayTeam.fullName) at \(homeTeam.fullName)"
}
var gameDateTime: String {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .short
return formatter.string(from: game.dateTime)
}
}
enum GameMatchResult: Sendable {
case singleMatch(GameMatchCandidate) // Auto-select
case multipleMatches([GameMatchCandidate]) // User selects (doubleheader, nearby stadiums)
case noMatches(NoMatchReason) // Manual entry required
var hasMatch: Bool {
switch self {
case .singleMatch, .multipleMatches:
return true
case .noMatches:
return false
}
}
}
// MARK: - Photo Import Result
struct PhotoImportCandidate: Identifiable, Sendable {
let id: UUID
let metadata: PhotoMetadata
let matchResult: GameMatchResult
let stadiumMatches: [StadiumMatch]
init(metadata: PhotoMetadata, matchResult: GameMatchResult, stadiumMatches: [StadiumMatch]) {
self.id = UUID()
self.metadata = metadata
self.matchResult = matchResult
self.stadiumMatches = stadiumMatches
}
/// Best stadium match if available
var bestStadiumMatch: StadiumMatch? {
stadiumMatches.first
}
/// Whether this can be auto-processed without user input
var canAutoProcess: Bool {
if case .singleMatch(let candidate) = matchResult {
return candidate.confidence.combined == .autoSelect
}
return false
}
}
// MARK: - Game Matcher
@MainActor
final class GameMatcher {
static let shared = GameMatcher()
private let dataProvider = AppDataProvider.shared
private let proximityMatcher = StadiumProximityMatcher.shared
private init() {}
// MARK: - Primary Matching
/// Match photo metadata to a game
/// Uses deterministic rules - never guesses
func matchGame(
metadata: PhotoMetadata,
sport: Sport? = nil
) async -> GameMatchResult {
// 1. Check for required metadata
guard metadata.hasValidLocation else {
let reason: NoMatchReason.MetadataMissingReason = metadata.hasValidDate ? .noLocation : .noBoth
return .noMatches(.metadataMissing(reason))
}
guard metadata.hasValidDate, let photoDate = metadata.captureDate else {
return .noMatches(.metadataMissing(.noDate))
}
guard let coordinates = metadata.coordinates else {
return .noMatches(.metadataMissing(.noLocation))
}
// 2. Find nearby stadiums
let stadiumMatches = proximityMatcher.findNearbyStadiums(
coordinates: coordinates,
sport: sport
)
guard !stadiumMatches.isEmpty else {
return .noMatches(.noStadiumNearby)
}
// 3. Find games at those stadiums on/around that date
var candidates: [GameMatchCandidate] = []
for stadiumMatch in stadiumMatches {
let games = await findGames(
at: stadiumMatch.stadium,
around: photoDate,
sport: sport
)
for game in games {
// Look up teams
guard let homeTeam = dataProvider.teams.first(where: { $0.id == game.homeTeamId }),
let awayTeam = dataProvider.teams.first(where: { $0.id == game.awayTeamId }) else {
continue
}
// Calculate confidence
let confidence = proximityMatcher.calculateMatchConfidence(
stadiumMatch: stadiumMatch,
photoDate: photoDate,
gameDate: game.dateTime
)
// Only include if temporal confidence is acceptable
if confidence.temporal != .outOfRange {
candidates.append(GameMatchCandidate(
game: game,
stadium: stadiumMatch.stadium,
homeTeam: homeTeam,
awayTeam: awayTeam,
confidence: confidence
))
}
}
}
// 4. Return based on matches found
if candidates.isEmpty {
return .noMatches(.noGamesOnDate)
} else if candidates.count == 1 {
return .singleMatch(candidates[0])
} else {
// Sort by confidence (best first)
let sorted = candidates.sorted { c1, c2 in
c1.confidence.combined > c2.confidence.combined
}
return .multipleMatches(sorted)
}
}
// MARK: - Full Import Processing
/// Process a photo for import, returning full match context
func processPhotoForImport(
metadata: PhotoMetadata,
sport: Sport? = nil
) async -> PhotoImportCandidate {
// Get stadium matches regardless of game matching
var stadiumMatches: [StadiumMatch] = []
if let coordinates = metadata.coordinates {
stadiumMatches = proximityMatcher.findNearbyStadiums(
coordinates: coordinates,
sport: sport
)
}
let matchResult = await matchGame(metadata: metadata, sport: sport)
return PhotoImportCandidate(
metadata: metadata,
matchResult: matchResult,
stadiumMatches: stadiumMatches
)
}
/// Process multiple photos for import
func processPhotosForImport(
_ metadataList: [PhotoMetadata],
sport: Sport? = nil
) async -> [PhotoImportCandidate] {
var results: [PhotoImportCandidate] = []
for metadata in metadataList {
let candidate = await processPhotoForImport(metadata: metadata, sport: sport)
results.append(candidate)
}
return results
}
// MARK: - Private Helpers
/// Find games at a stadium around a given date (±1 day for timezone/tailgating)
private func findGames(
at stadium: Stadium,
around date: Date,
sport: Sport?
) async -> [Game] {
let calendar = Calendar.current
// Search window: ±1 day
guard let startDate = calendar.date(byAdding: .day, value: -1, to: date),
let endDate = calendar.date(byAdding: .day, value: 2, to: date) else {
return []
}
// Determine which sports to query
let sports: Set<Sport> = sport != nil ? [sport!] : Set(Sport.allCases)
do {
let allGames = try await dataProvider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
// Filter by stadium
let games = allGames.filter { $0.stadiumId == stadium.id }
return games
} catch {
return []
}
}
}
// MARK: - Batch Processing Helpers
extension GameMatcher {
/// Separate photos into categories for UI
struct CategorizedImports: Sendable {
let autoProcessable: [PhotoImportCandidate]
let needsConfirmation: [PhotoImportCandidate]
let needsManualEntry: [PhotoImportCandidate]
}
nonisolated func categorizeImports(_ candidates: [PhotoImportCandidate]) -> CategorizedImports {
var auto: [PhotoImportCandidate] = []
var confirm: [PhotoImportCandidate] = []
var manual: [PhotoImportCandidate] = []
for candidate in candidates {
switch candidate.matchResult {
case .singleMatch(let match):
if match.confidence.combined == .autoSelect {
auto.append(candidate)
} else {
confirm.append(candidate)
}
case .multipleMatches:
confirm.append(candidate)
case .noMatches:
manual.append(candidate)
}
}
return CategorizedImports(
autoProcessable: auto,
needsConfirmation: confirm,
needsManualEntry: manual
)
}
}

View File

@@ -0,0 +1,200 @@
//
// PhotoMetadataExtractor.swift
// SportsTime
//
// Service for extracting EXIF metadata (GPS, date) from photos.
//
import Foundation
import Photos
import CoreLocation
import ImageIO
import UIKit
// MARK: - Photo Metadata
struct PhotoMetadata: Sendable {
let captureDate: Date?
let coordinates: CLLocationCoordinate2D?
let hasValidLocation: Bool
let hasValidDate: Bool
nonisolated init(captureDate: Date?, coordinates: CLLocationCoordinate2D?) {
self.captureDate = captureDate
self.coordinates = coordinates
self.hasValidLocation = coordinates != nil
self.hasValidDate = captureDate != nil
}
nonisolated static var empty: PhotoMetadata {
PhotoMetadata(captureDate: nil, coordinates: nil)
}
}
// MARK: - Photo Metadata Extractor
actor PhotoMetadataExtractor {
static let shared = PhotoMetadataExtractor()
private init() {}
// MARK: - PHAsset Extraction (Preferred)
/// Extract metadata from PHAsset (preferred method)
/// Uses PHAsset's location and creationDate properties
func extractMetadata(from asset: PHAsset) async -> PhotoMetadata {
// PHAsset provides location and date directly
let coordinates: CLLocationCoordinate2D?
if let location = asset.location {
coordinates = location.coordinate
} else {
coordinates = nil
}
return PhotoMetadata(
captureDate: asset.creationDate,
coordinates: coordinates
)
}
/// Extract metadata from multiple PHAssets
func extractMetadata(from assets: [PHAsset]) async -> [PhotoMetadata] {
var results: [PhotoMetadata] = []
for asset in assets {
let metadata = await extractMetadata(from: asset)
results.append(metadata)
}
return results
}
// MARK: - Image Data Extraction (Fallback)
/// Extract metadata from raw image data using ImageIO
/// Useful when PHAsset is not available
func extractMetadata(from imageData: Data) -> PhotoMetadata {
guard let source = CGImageSourceCreateWithData(imageData as CFData, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(source, 0, nil) as? [CFString: Any] else {
return .empty
}
let captureDate = extractDate(from: properties)
let coordinates = extractCoordinates(from: properties)
return PhotoMetadata(
captureDate: captureDate,
coordinates: coordinates
)
}
// MARK: - Private Helpers
private func extractDate(from properties: [CFString: Any]) -> Date? {
// Try EXIF DateTimeOriginal first
if let exif = properties[kCGImagePropertyExifDictionary] as? [CFString: Any],
let dateString = exif[kCGImagePropertyExifDateTimeOriginal] as? String {
return parseExifDate(dateString)
}
// Try TIFF DateTime
if let tiff = properties[kCGImagePropertyTIFFDictionary] as? [CFString: Any],
let dateString = tiff[kCGImagePropertyTIFFDateTime] as? String {
return parseExifDate(dateString)
}
return nil
}
private func extractCoordinates(from properties: [CFString: Any]) -> CLLocationCoordinate2D? {
guard let gps = properties[kCGImagePropertyGPSDictionary] as? [CFString: Any],
let latitude = gps[kCGImagePropertyGPSLatitude] as? Double,
let longitude = gps[kCGImagePropertyGPSLongitude] as? Double else {
return nil
}
// Handle N/S and E/W references
var lat = latitude
var lon = longitude
if let latRef = gps[kCGImagePropertyGPSLatitudeRef] as? String, latRef == "S" {
lat = -lat
}
if let lonRef = gps[kCGImagePropertyGPSLongitudeRef] as? String, lonRef == "W" {
lon = -lon
}
// Validate coordinates
guard lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180 else {
return nil
}
return CLLocationCoordinate2D(latitude: lat, longitude: lon)
}
private func parseExifDate(_ dateString: String) -> Date? {
// EXIF date format: "2024:06:15 14:30:00"
let formatter = DateFormatter()
formatter.dateFormat = "yyyy:MM:dd HH:mm:ss"
formatter.locale = Locale(identifier: "en_US_POSIX")
return formatter.date(from: dateString)
}
}
// MARK: - Photo Library Access
extension PhotoMetadataExtractor {
/// Request photo library access
@MainActor
func requestPhotoLibraryAccess() async -> PHAuthorizationStatus {
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
switch status {
case .notDetermined:
return await PHPhotoLibrary.requestAuthorization(for: .readWrite)
default:
return status
}
}
/// Check if photo library access is available
var hasPhotoLibraryAccess: Bool {
let status = PHPhotoLibrary.authorizationStatus(for: .readWrite)
return status == .authorized || status == .limited
}
}
// MARK: - Asset Image Loading
extension PhotoMetadataExtractor {
/// Load thumbnail image from PHAsset
func loadThumbnail(from asset: PHAsset, targetSize: CGSize = CGSize(width: 200, height: 200)) async -> UIImage? {
await withCheckedContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .fastFormat
options.resizeMode = .fast
options.isSynchronous = false
PHImageManager.default().requestImage(
for: asset,
targetSize: targetSize,
contentMode: .aspectFill,
options: options
) { image, _ in
continuation.resume(returning: image)
}
}
}
/// Load full-size image data from PHAsset
func loadImageData(from asset: PHAsset) async -> Data? {
await withCheckedContinuation { continuation in
let options = PHImageRequestOptions()
options.deliveryMode = .highQualityFormat
options.isSynchronous = false
PHImageManager.default().requestImageDataAndOrientation(for: asset, options: options) { data, _, _, _ in
continuation.resume(returning: data)
}
}
}
}

View File

@@ -0,0 +1,208 @@
//
// RateLimiter.swift
// SportsTime
//
// Rate limiting for API providers to respect their rate limits.
//
import Foundation
// MARK: - Rate Limiter
/// Per-provider rate limiting to avoid hitting API limits
actor RateLimiter {
// MARK: - Types
struct ProviderConfig {
let name: String
let minInterval: TimeInterval // Minimum time between requests
let burstLimit: Int // Max requests in burst window
let burstWindow: TimeInterval // Window for burst counting
}
// MARK: - Properties
private var lastRequestTimes: [String: Date] = [:]
private var requestCounts: [String: [Date]] = [:]
private var configs: [String: ProviderConfig] = [:]
// MARK: - Default Configurations
/// Default provider rate limit configurations
private static let defaultConfigs: [ProviderConfig] = [
ProviderConfig(name: "mlb_stats", minInterval: 0.1, burstLimit: 30, burstWindow: 60), // 10 req/sec
ProviderConfig(name: "nhl_stats", minInterval: 0.2, burstLimit: 20, burstWindow: 60), // 5 req/sec
ProviderConfig(name: "nba_stats", minInterval: 0.5, burstLimit: 10, burstWindow: 60), // 2 req/sec
ProviderConfig(name: "espn", minInterval: 1.0, burstLimit: 30, burstWindow: 60), // 1 req/sec
ProviderConfig(name: "sports_reference", minInterval: 3.0, burstLimit: 10, burstWindow: 60) // 1 req/3 sec
]
// MARK: - Singleton
static let shared = RateLimiter()
private init() {
// Load default configs
for config in Self.defaultConfigs {
configs[config.name] = config
}
}
// MARK: - Configuration
/// Configure rate limiting for a provider
func configureProvider(_ config: ProviderConfig) {
configs[config.name] = config
}
// MARK: - Rate Limiting
/// Wait if needed to respect rate limits for a provider
/// Returns immediately if rate limit allows, otherwise sleeps until allowed
func waitIfNeeded(for provider: String) async {
let config = configs[provider] ?? ProviderConfig(
name: provider,
minInterval: 1.0,
burstLimit: 60,
burstWindow: 60
)
await enforceMinInterval(for: provider, interval: config.minInterval)
await enforceBurstLimit(for: provider, limit: config.burstLimit, window: config.burstWindow)
recordRequest(for: provider)
}
/// Check if a request can be made without waiting
func canMakeRequest(for provider: String) -> Bool {
let config = configs[provider] ?? ProviderConfig(
name: provider,
minInterval: 1.0,
burstLimit: 60,
burstWindow: 60
)
// Check min interval
if let lastRequest = lastRequestTimes[provider] {
let elapsed = Date().timeIntervalSince(lastRequest)
if elapsed < config.minInterval {
return false
}
}
// Check burst limit
let now = Date()
let windowStart = now.addingTimeInterval(-config.burstWindow)
if let requests = requestCounts[provider] {
let recentRequests = requests.filter { $0 > windowStart }
if recentRequests.count >= config.burstLimit {
return false
}
}
return true
}
/// Get estimated wait time until next request is allowed
func estimatedWaitTime(for provider: String) -> TimeInterval {
let config = configs[provider] ?? ProviderConfig(
name: provider,
minInterval: 1.0,
burstLimit: 60,
burstWindow: 60
)
var maxWait: TimeInterval = 0
// Check min interval wait
if let lastRequest = lastRequestTimes[provider] {
let elapsed = Date().timeIntervalSince(lastRequest)
if elapsed < config.minInterval {
maxWait = max(maxWait, config.minInterval - elapsed)
}
}
// Check burst limit wait
let now = Date()
let windowStart = now.addingTimeInterval(-config.burstWindow)
if let requests = requestCounts[provider] {
let recentRequests = requests.filter { $0 > windowStart }.sorted()
if recentRequests.count >= config.burstLimit {
// Need to wait until oldest request falls out of window
if let oldestInWindow = recentRequests.first {
let waitUntil = oldestInWindow.addingTimeInterval(config.burstWindow)
let wait = waitUntil.timeIntervalSince(now)
maxWait = max(maxWait, wait)
}
}
}
return maxWait
}
/// Reset rate limit tracking for a provider
func reset(for provider: String) {
lastRequestTimes.removeValue(forKey: provider)
requestCounts.removeValue(forKey: provider)
}
/// Reset all rate limit tracking
func resetAll() {
lastRequestTimes.removeAll()
requestCounts.removeAll()
}
// MARK: - Private Helpers
private func enforceMinInterval(for provider: String, interval: TimeInterval) async {
if let lastRequest = lastRequestTimes[provider] {
let elapsed = Date().timeIntervalSince(lastRequest)
if elapsed < interval {
let waitTime = interval - elapsed
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
}
}
}
private func enforceBurstLimit(for provider: String, limit: Int, window: TimeInterval) async {
let now = Date()
let windowStart = now.addingTimeInterval(-window)
// Clean up old requests
if var requests = requestCounts[provider] {
requests = requests.filter { $0 > windowStart }
requestCounts[provider] = requests
// Check if at limit
if requests.count >= limit {
// Wait until oldest request falls out of window
if let oldestInWindow = requests.sorted().first {
let waitUntil = oldestInWindow.addingTimeInterval(window)
let waitTime = waitUntil.timeIntervalSince(now)
if waitTime > 0 {
try? await Task.sleep(nanoseconds: UInt64(waitTime * 1_000_000_000))
}
}
}
}
}
private func recordRequest(for provider: String) {
let now = Date()
lastRequestTimes[provider] = now
if requestCounts[provider] == nil {
requestCounts[provider] = []
}
requestCounts[provider]?.append(now)
// Clean up old requests periodically
if let requests = requestCounts[provider], requests.count > 1000 {
let oneHourAgo = now.addingTimeInterval(-3600)
requestCounts[provider] = requests.filter { $0 > oneHourAgo }
}
}
}

View File

@@ -0,0 +1,173 @@
//
// MLBStatsProvider.swift
// SportsTime
//
// MLB Stats API provider - official, documented, stable.
// API: https://statsapi.mlb.com
//
import Foundation
// MARK: - MLB Stats Provider
struct MLBStatsProvider: ScoreAPIProvider {
// MARK: - Protocol Requirements
let name = "MLB Stats API"
let supportedSports: Set<Sport> = [.mlb]
let reliability: ProviderReliability = .official
let rateLimitKey = "mlb_stats"
// MARK: - API Configuration
private let baseURL = "https://statsapi.mlb.com/api/v1"
// MARK: - Team ID Mapping
/// Maps team abbreviations to MLB Stats API team IDs
private static let teamIdMapping: [String: Int] = [
"ARI": 109, "ATL": 144, "BAL": 110, "BOS": 111,
"CHC": 112, "CWS": 145, "CIN": 113, "CLE": 114,
"COL": 115, "DET": 116, "HOU": 117, "KC": 118,
"LAA": 108, "LAD": 119, "MIA": 146, "MIL": 158,
"MIN": 142, "NYM": 121, "NYY": 147, "OAK": 133,
"PHI": 143, "PIT": 134, "SD": 135, "SF": 137,
"SEA": 136, "STL": 138, "TB": 139, "TEX": 140,
"TOR": 141, "WSH": 120
]
// Reverse mapping for API response
private static let idToAbbrevMapping: [Int: String] = {
Dictionary(uniqueKeysWithValues: teamIdMapping.map { ($1, $0) })
}()
// MARK: - Fetch Game
func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? {
guard query.sport == .mlb else {
throw ScoreProviderError.unsupportedSport(query.sport)
}
// Build schedule URL for the date
let dateString = query.normalizedDateString
let urlString = "\(baseURL)/schedule?sportId=1&date=\(dateString)&hydrate=team,linescore"
guard let url = URL(string: urlString) else {
throw ScoreProviderError.networkError(underlying: "Invalid URL")
}
// Fetch data
let (data, response) = try await URLSession.shared.data(from: url)
// Check HTTP response
guard let httpResponse = response as? HTTPURLResponse else {
throw ScoreProviderError.networkError(underlying: "Invalid response type")
}
guard httpResponse.statusCode == 200 else {
if httpResponse.statusCode == 429 {
throw ScoreProviderError.rateLimited
}
throw ScoreProviderError.networkError(underlying: "HTTP \(httpResponse.statusCode)")
}
// Parse response
return try parseScheduleResponse(data: data, query: query)
}
// MARK: - Response Parsing
private func parseScheduleResponse(data: Data, query: HistoricalGameQuery) throws -> HistoricalGameResult? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let dates = json["dates"] as? [[String: Any]] else {
throw ScoreProviderError.parseError(message: "Invalid JSON structure")
}
// Find games on the requested date
for dateEntry in dates {
guard let games = dateEntry["games"] as? [[String: Any]] else { continue }
for game in games {
// Extract team info
guard let teams = game["teams"] as? [String: Any],
let homeTeamData = teams["home"] as? [String: Any],
let awayTeamData = teams["away"] as? [String: Any],
let homeTeam = homeTeamData["team"] as? [String: Any],
let awayTeam = awayTeamData["team"] as? [String: Any],
let homeTeamId = homeTeam["id"] as? Int,
let awayTeamId = awayTeam["id"] as? Int else {
continue
}
// Get team abbreviations
guard let homeAbbrev = Self.idToAbbrevMapping[homeTeamId],
let awayAbbrev = Self.idToAbbrevMapping[awayTeamId] else {
continue
}
// Check if this matches the query
if let queryHome = query.homeTeamAbbrev, queryHome.uppercased() != homeAbbrev {
continue
}
if let queryAway = query.awayTeamAbbrev, queryAway.uppercased() != awayAbbrev {
continue
}
// Extract team names
let homeTeamName = homeTeam["name"] as? String ?? homeAbbrev
let awayTeamName = awayTeam["name"] as? String ?? awayAbbrev
// Extract scores from linescore if available
var homeScore: Int?
var awayScore: Int?
if let linescore = game["linescore"] as? [String: Any],
let lineTeams = linescore["teams"] as? [String: Any] {
if let homeLineData = lineTeams["home"] as? [String: Any] {
homeScore = homeLineData["runs"] as? Int
}
if let awayLineData = lineTeams["away"] as? [String: Any] {
awayScore = awayLineData["runs"] as? Int
}
}
// Alternative: get scores from team data
if homeScore == nil, let score = homeTeamData["score"] as? Int {
homeScore = score
}
if awayScore == nil, let score = awayTeamData["score"] as? Int {
awayScore = score
}
// Extract game date
let gameDate: Date
if let gameDateString = game["gameDate"] as? String {
let formatter = ISO8601DateFormatter()
gameDate = formatter.date(from: gameDateString) ?? query.date
} else {
gameDate = query.date
}
return HistoricalGameResult(
sport: .mlb,
gameDate: gameDate,
homeTeamAbbrev: homeAbbrev,
awayTeamAbbrev: awayAbbrev,
homeTeamName: homeTeamName,
awayTeamName: awayTeamName,
homeScore: homeScore,
awayScore: awayScore,
source: .api,
providerName: name
)
}
}
return nil
}
}
// MARK: - Sendable Conformance
extension MLBStatsProvider: Sendable {}

View File

@@ -0,0 +1,215 @@
//
// NBAStatsProvider.swift
// SportsTime
//
// NBA Stats API provider - unofficial but functional.
// API: https://stats.nba.com
// Note: Requires specific headers, may break without notice.
//
import Foundation
// MARK: - NBA Stats Provider
struct NBAStatsProvider: ScoreAPIProvider {
// MARK: - Protocol Requirements
let name = "NBA Stats API"
let supportedSports: Set<Sport> = [.nba]
let reliability: ProviderReliability = .unofficial
let rateLimitKey = "nba_stats"
// MARK: - API Configuration
private let baseURL = "https://stats.nba.com/stats"
// Required headers to avoid 403 errors
private let requiredHeaders: [String: String] = [
"Host": "stats.nba.com",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.5",
"Referer": "https://www.nba.com/",
"x-nba-stats-origin": "stats",
"x-nba-stats-token": "true",
"Connection": "keep-alive"
]
// MARK: - Team ID Mapping
/// Maps team abbreviations to NBA Stats API team IDs
private static let teamIdMapping: [String: Int] = [
"ATL": 1610612737, "BOS": 1610612738, "BKN": 1610612751, "CHA": 1610612766,
"CHI": 1610612741, "CLE": 1610612739, "DAL": 1610612742, "DEN": 1610612743,
"DET": 1610612765, "GSW": 1610612744, "HOU": 1610612745, "IND": 1610612754,
"LAC": 1610612746, "LAL": 1610612747, "MEM": 1610612763, "MIA": 1610612748,
"MIL": 1610612749, "MIN": 1610612750, "NOP": 1610612740, "NYK": 1610612752,
"OKC": 1610612760, "ORL": 1610612753, "PHI": 1610612755, "PHX": 1610612756,
"POR": 1610612757, "SAC": 1610612758, "SAS": 1610612759, "TOR": 1610612761,
"UTA": 1610612762, "WAS": 1610612764
]
// Reverse mapping
private static let idToAbbrevMapping: [Int: String] = {
Dictionary(uniqueKeysWithValues: teamIdMapping.map { ($1, $0) })
}()
// Team names
private static let teamNames: [String: String] = [
"ATL": "Atlanta Hawks", "BOS": "Boston Celtics", "BKN": "Brooklyn Nets",
"CHA": "Charlotte Hornets", "CHI": "Chicago Bulls", "CLE": "Cleveland Cavaliers",
"DAL": "Dallas Mavericks", "DEN": "Denver Nuggets", "DET": "Detroit Pistons",
"GSW": "Golden State Warriors", "HOU": "Houston Rockets", "IND": "Indiana Pacers",
"LAC": "Los Angeles Clippers", "LAL": "Los Angeles Lakers", "MEM": "Memphis Grizzlies",
"MIA": "Miami Heat", "MIL": "Milwaukee Bucks", "MIN": "Minnesota Timberwolves",
"NOP": "New Orleans Pelicans", "NYK": "New York Knicks", "OKC": "Oklahoma City Thunder",
"ORL": "Orlando Magic", "PHI": "Philadelphia 76ers", "PHX": "Phoenix Suns",
"POR": "Portland Trail Blazers", "SAC": "Sacramento Kings", "SAS": "San Antonio Spurs",
"TOR": "Toronto Raptors", "UTA": "Utah Jazz", "WAS": "Washington Wizards"
]
// MARK: - Fetch Game
func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? {
guard query.sport == .nba else {
throw ScoreProviderError.unsupportedSport(query.sport)
}
// Build scoreboard URL for the date
let dateString = query.normalizedDateString.replacingOccurrences(of: "-", with: "")
let urlString = "\(baseURL)/scoreboardv2?GameDate=\(dateString)&LeagueID=00&DayOffset=0"
guard let url = URL(string: urlString) else {
throw ScoreProviderError.networkError(underlying: "Invalid URL")
}
// Create request with required headers
var request = URLRequest(url: url)
request.httpMethod = "GET"
for (key, value) in requiredHeaders {
request.setValue(value, forHTTPHeaderField: key)
}
// Fetch data
let (data, response) = try await URLSession.shared.data(for: request)
// Check HTTP response
guard let httpResponse = response as? HTTPURLResponse else {
throw ScoreProviderError.networkError(underlying: "Invalid response type")
}
guard httpResponse.statusCode == 200 else {
if httpResponse.statusCode == 429 {
throw ScoreProviderError.rateLimited
}
if httpResponse.statusCode == 403 {
throw ScoreProviderError.providerUnavailable(reason: "Access denied - headers may need update")
}
throw ScoreProviderError.networkError(underlying: "HTTP \(httpResponse.statusCode)")
}
// Parse response
return try parseScoreboardResponse(data: data, query: query)
}
// MARK: - Response Parsing
private func parseScoreboardResponse(data: Data, query: HistoricalGameQuery) throws -> HistoricalGameResult? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let resultSets = json["resultSets"] as? [[String: Any]] else {
throw ScoreProviderError.parseError(message: "Invalid JSON structure")
}
// Find the GameHeader result set
guard let gameHeaderSet = resultSets.first(where: { ($0["name"] as? String) == "GameHeader" }),
let headers = gameHeaderSet["headers"] as? [String],
let rowSet = gameHeaderSet["rowSet"] as? [[Any]] else {
return nil
}
// Get column indices
let homeTeamIdIdx = headers.firstIndex(of: "HOME_TEAM_ID")
let visitorTeamIdIdx = headers.firstIndex(of: "VISITOR_TEAM_ID")
let gameStatusIdx = headers.firstIndex(of: "GAME_STATUS_TEXT")
// Find the LineScore result set for scores
let lineScoreSet = resultSets.first(where: { ($0["name"] as? String) == "LineScore" })
let lineScoreHeaders = lineScoreSet?["headers"] as? [String]
let lineScoreRows = lineScoreSet?["rowSet"] as? [[Any]]
let teamIdScoreIdx = lineScoreHeaders?.firstIndex(of: "TEAM_ID")
let ptsIdx = lineScoreHeaders?.firstIndex(of: "PTS")
// Process each game
for row in rowSet {
guard let homeTeamIdIdx = homeTeamIdIdx,
let visitorTeamIdIdx = visitorTeamIdIdx,
homeTeamIdIdx < row.count,
visitorTeamIdIdx < row.count,
let homeTeamId = row[homeTeamIdIdx] as? Int,
let awayTeamId = row[visitorTeamIdIdx] as? Int else {
continue
}
// Get team abbreviations
guard let homeAbbrev = Self.idToAbbrevMapping[homeTeamId],
let awayAbbrev = Self.idToAbbrevMapping[awayTeamId] else {
continue
}
// Check if this matches the query
if let queryHome = query.homeTeamAbbrev, queryHome.uppercased() != homeAbbrev {
continue
}
if let queryAway = query.awayTeamAbbrev, queryAway.uppercased() != awayAbbrev {
continue
}
// Get team names
let homeTeamName = Self.teamNames[homeAbbrev] ?? homeAbbrev
let awayTeamName = Self.teamNames[awayAbbrev] ?? awayAbbrev
// Get scores from LineScore
var homeScore: Int?
var awayScore: Int?
if let lineScoreRows = lineScoreRows,
let teamIdScoreIdx = teamIdScoreIdx,
let ptsIdx = ptsIdx {
for scoreRow in lineScoreRows {
guard teamIdScoreIdx < scoreRow.count,
ptsIdx < scoreRow.count,
let teamId = scoreRow[teamIdScoreIdx] as? Int else {
continue
}
if teamId == homeTeamId {
homeScore = scoreRow[ptsIdx] as? Int
} else if teamId == awayTeamId {
awayScore = scoreRow[ptsIdx] as? Int
}
}
}
return HistoricalGameResult(
sport: .nba,
gameDate: query.date,
homeTeamAbbrev: homeAbbrev,
awayTeamAbbrev: awayAbbrev,
homeTeamName: homeTeamName,
awayTeamName: awayTeamName,
homeScore: homeScore,
awayScore: awayScore,
source: .api,
providerName: name
)
}
return nil
}
}
// MARK: - Sendable Conformance
extension NBAStatsProvider: Sendable {}

View File

@@ -0,0 +1,172 @@
//
// NHLStatsProvider.swift
// SportsTime
//
// NHL Stats API provider - official, documented, stable.
// API: https://api-web.nhle.com
//
import Foundation
// MARK: - NHL Stats Provider
struct NHLStatsProvider: ScoreAPIProvider {
// MARK: - Protocol Requirements
let name = "NHL Stats API"
let supportedSports: Set<Sport> = [.nhl]
let reliability: ProviderReliability = .official
let rateLimitKey = "nhl_stats"
// MARK: - API Configuration
private let baseURL = "https://api-web.nhle.com/v1"
// MARK: - Team Abbreviation Mapping
/// Maps common team abbreviations to NHL API team codes
private static let teamAbbrevMapping: [String: String] = [
"ANA": "ANA", "ARI": "ARI", "BOS": "BOS", "BUF": "BUF",
"CGY": "CGY", "CAR": "CAR", "CHI": "CHI", "COL": "COL",
"CBJ": "CBJ", "DAL": "DAL", "DET": "DET", "EDM": "EDM",
"FLA": "FLA", "LA": "LAK", "LAK": "LAK", "MIN": "MIN",
"MTL": "MTL", "NSH": "NSH", "NJ": "NJD", "NJD": "NJD",
"NYI": "NYI", "NYR": "NYR", "OTT": "OTT", "PHI": "PHI",
"PIT": "PIT", "SJ": "SJS", "SJS": "SJS", "SEA": "SEA",
"STL": "STL", "TB": "TBL", "TBL": "TBL", "TOR": "TOR",
"UTA": "UTA", "VAN": "VAN", "VGK": "VGK", "WSH": "WSH",
"WPG": "WPG"
]
// Team names for display
private static let teamNames: [String: String] = [
"ANA": "Anaheim Ducks", "ARI": "Arizona Coyotes", "BOS": "Boston Bruins",
"BUF": "Buffalo Sabres", "CGY": "Calgary Flames", "CAR": "Carolina Hurricanes",
"CHI": "Chicago Blackhawks", "COL": "Colorado Avalanche",
"CBJ": "Columbus Blue Jackets", "DAL": "Dallas Stars", "DET": "Detroit Red Wings",
"EDM": "Edmonton Oilers", "FLA": "Florida Panthers", "LAK": "Los Angeles Kings",
"MIN": "Minnesota Wild", "MTL": "Montreal Canadiens", "NSH": "Nashville Predators",
"NJD": "New Jersey Devils", "NYI": "New York Islanders", "NYR": "New York Rangers",
"OTT": "Ottawa Senators", "PHI": "Philadelphia Flyers", "PIT": "Pittsburgh Penguins",
"SJS": "San Jose Sharks", "SEA": "Seattle Kraken", "STL": "St. Louis Blues",
"TBL": "Tampa Bay Lightning", "TOR": "Toronto Maple Leafs", "UTA": "Utah Hockey Club",
"VAN": "Vancouver Canucks", "VGK": "Vegas Golden Knights",
"WSH": "Washington Capitals", "WPG": "Winnipeg Jets"
]
// MARK: - Fetch Game
func fetchGame(query: HistoricalGameQuery) async throws -> HistoricalGameResult? {
guard query.sport == .nhl else {
throw ScoreProviderError.unsupportedSport(query.sport)
}
// Build schedule URL for the date
let dateString = query.normalizedDateString
let urlString = "\(baseURL)/schedule/\(dateString)"
guard let url = URL(string: urlString) else {
throw ScoreProviderError.networkError(underlying: "Invalid URL")
}
// Fetch data
let (data, response) = try await URLSession.shared.data(from: url)
// Check HTTP response
guard let httpResponse = response as? HTTPURLResponse else {
throw ScoreProviderError.networkError(underlying: "Invalid response type")
}
guard httpResponse.statusCode == 200 else {
if httpResponse.statusCode == 429 {
throw ScoreProviderError.rateLimited
}
throw ScoreProviderError.networkError(underlying: "HTTP \(httpResponse.statusCode)")
}
// Parse response
return try parseScheduleResponse(data: data, query: query)
}
// MARK: - Response Parsing
private func parseScheduleResponse(data: Data, query: HistoricalGameQuery) throws -> HistoricalGameResult? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let gameWeek = json["gameWeek"] as? [[String: Any]] else {
throw ScoreProviderError.parseError(message: "Invalid JSON structure")
}
// Find games on the requested date
for dayEntry in gameWeek {
guard let games = dayEntry["games"] as? [[String: Any]] else { continue }
for game in games {
// Extract team info
guard let homeTeam = game["homeTeam"] as? [String: Any],
let awayTeam = game["awayTeam"] as? [String: Any],
let homeAbbrevRaw = homeTeam["abbrev"] as? String,
let awayAbbrevRaw = awayTeam["abbrev"] as? String else {
continue
}
// Normalize abbreviations
let homeAbbrev = Self.teamAbbrevMapping[homeAbbrevRaw.uppercased()] ?? homeAbbrevRaw.uppercased()
let awayAbbrev = Self.teamAbbrevMapping[awayAbbrevRaw.uppercased()] ?? awayAbbrevRaw.uppercased()
// Check if this matches the query
if let queryHome = query.homeTeamAbbrev {
let normalizedQueryHome = Self.teamAbbrevMapping[queryHome.uppercased()] ?? queryHome.uppercased()
if normalizedQueryHome != homeAbbrev {
continue
}
}
if let queryAway = query.awayTeamAbbrev {
let normalizedQueryAway = Self.teamAbbrevMapping[queryAway.uppercased()] ?? queryAway.uppercased()
if normalizedQueryAway != awayAbbrev {
continue
}
}
// Extract team names
let homeTeamName = homeTeam["placeName"] as? [String: Any]
let homeTeamNameDefault = (homeTeamName?["default"] as? String) ?? Self.teamNames[homeAbbrev] ?? homeAbbrev
let awayTeamName = awayTeam["placeName"] as? [String: Any]
let awayTeamNameDefault = (awayTeamName?["default"] as? String) ?? Self.teamNames[awayAbbrev] ?? awayAbbrev
// Extract scores
let homeScore = homeTeam["score"] as? Int
let awayScore = awayTeam["score"] as? Int
// Extract game date
let gameDate: Date
if let startTime = game["startTimeUTC"] as? String {
let formatter = ISO8601DateFormatter()
gameDate = formatter.date(from: startTime) ?? query.date
} else {
gameDate = query.date
}
return HistoricalGameResult(
sport: .nhl,
gameDate: gameDate,
homeTeamAbbrev: homeAbbrev,
awayTeamAbbrev: awayAbbrev,
homeTeamName: homeTeamNameDefault,
awayTeamName: awayTeamNameDefault,
homeScore: homeScore,
awayScore: awayScore,
source: .api,
providerName: name
)
}
}
return nil
}
}
// MARK: - Sendable Conformance
extension NHLStatsProvider: Sendable {}

View File

@@ -0,0 +1,312 @@
//
// ScoreResolutionCache.swift
// SportsTime
//
// Manages caching of resolved game scores using SwiftData.
// Historical scores never change, so they can be cached indefinitely.
//
import Foundation
import SwiftData
// MARK: - Score Resolution Cache
@MainActor
final class ScoreResolutionCache {
// MARK: - Properties
private let modelContext: ModelContext
// Cache configuration
private static let recentGameCacheDuration: TimeInterval = 24 * 60 * 60 // 24 hours
private static let failedLookupCacheDuration: TimeInterval = 7 * 24 * 60 * 60 // 7 days
private static let historicalAgeThreshold: TimeInterval = 30 * 24 * 60 * 60 // 30 days
// MARK: - Initialization
init(modelContext: ModelContext) {
self.modelContext = modelContext
}
// MARK: - Cache Operations
/// Get cached score for a game query
func getCached(query: HistoricalGameQuery) -> CachedGameScore? {
guard let homeAbbrev = query.homeTeamAbbrev,
let awayAbbrev = query.awayTeamAbbrev else {
return nil
}
let cacheKey = CachedGameScore.generateKey(
sport: query.sport,
date: query.date,
homeAbbrev: homeAbbrev,
awayAbbrev: awayAbbrev
)
let descriptor = FetchDescriptor<CachedGameScore>(
predicate: #Predicate { $0.cacheKey == cacheKey }
)
do {
let results = try modelContext.fetch(descriptor)
if let cached = results.first {
// Check if expired
if cached.isExpired {
// Delete expired entry
modelContext.delete(cached)
try? modelContext.save()
return nil
}
return cached
}
} catch {
// Fetch failed, return nil
}
return nil
}
/// Convert cached score to HistoricalGameResult
func getCachedResult(query: HistoricalGameQuery) -> HistoricalGameResult? {
guard let cached = getCached(query: query) else {
return nil
}
return HistoricalGameResult(
sport: cached.sportEnum ?? query.sport,
gameDate: cached.gameDate,
homeTeamAbbrev: cached.homeTeamAbbrev,
awayTeamAbbrev: cached.awayTeamAbbrev,
homeTeamName: cached.homeTeamName,
awayTeamName: cached.awayTeamName,
homeScore: cached.homeScore,
awayScore: cached.awayScore,
source: cached.scoreSource,
providerName: "cache"
)
}
/// Cache a resolved game result
func cache(result: HistoricalGameResult, query: HistoricalGameQuery) {
let cacheKey = CachedGameScore.generateKey(
sport: result.sport,
date: result.gameDate,
homeAbbrev: result.homeTeamAbbrev,
awayAbbrev: result.awayTeamAbbrev
)
// Check if already cached
let descriptor = FetchDescriptor<CachedGameScore>(
predicate: #Predicate { $0.cacheKey == cacheKey }
)
do {
let existing = try modelContext.fetch(descriptor)
if let existingEntry = existing.first {
// Update existing entry
existingEntry.homeScore = result.homeScore
existingEntry.awayScore = result.awayScore
existingEntry.sourceRaw = result.source.rawValue
existingEntry.fetchedAt = Date()
existingEntry.expiresAt = calculateExpiration(for: result.gameDate)
} else {
// Create new entry
let cached = CachedGameScore(
cacheKey: cacheKey,
sport: result.sport,
gameDate: result.gameDate,
homeTeamAbbrev: result.homeTeamAbbrev,
awayTeamAbbrev: result.awayTeamAbbrev,
homeTeamName: result.homeTeamName,
awayTeamName: result.awayTeamName,
homeScore: result.homeScore,
awayScore: result.awayScore,
source: result.source,
expiresAt: calculateExpiration(for: result.gameDate)
)
modelContext.insert(cached)
}
try modelContext.save()
} catch {
// Cache save failed, continue without caching
}
}
/// Cache a failed lookup to avoid repeated failures
func cacheFailedLookup(query: HistoricalGameQuery) {
guard let homeAbbrev = query.homeTeamAbbrev,
let awayAbbrev = query.awayTeamAbbrev else {
return
}
let cacheKey = CachedGameScore.generateKey(
sport: query.sport,
date: query.date,
homeAbbrev: homeAbbrev,
awayAbbrev: awayAbbrev
)
// Check if already cached
let descriptor = FetchDescriptor<CachedGameScore>(
predicate: #Predicate { $0.cacheKey == cacheKey }
)
do {
let existing = try modelContext.fetch(descriptor)
if existing.isEmpty {
// Create failed lookup entry (no scores)
let cached = CachedGameScore(
cacheKey: cacheKey,
sport: query.sport,
gameDate: query.date,
homeTeamAbbrev: homeAbbrev,
awayTeamAbbrev: awayAbbrev,
homeTeamName: homeAbbrev,
awayTeamName: awayAbbrev,
homeScore: nil,
awayScore: nil,
source: .api,
expiresAt: Date().addingTimeInterval(Self.failedLookupCacheDuration)
)
modelContext.insert(cached)
try modelContext.save()
}
} catch {
// Ignore cache failures
}
}
/// Remove a cached entry
func invalidate(query: HistoricalGameQuery) {
guard let homeAbbrev = query.homeTeamAbbrev,
let awayAbbrev = query.awayTeamAbbrev else {
return
}
let cacheKey = CachedGameScore.generateKey(
sport: query.sport,
date: query.date,
homeAbbrev: homeAbbrev,
awayAbbrev: awayAbbrev
)
let descriptor = FetchDescriptor<CachedGameScore>(
predicate: #Predicate { $0.cacheKey == cacheKey }
)
do {
let results = try modelContext.fetch(descriptor)
for entry in results {
modelContext.delete(entry)
}
try modelContext.save()
} catch {
// Ignore deletion failures
}
}
/// Clean up expired cache entries
func cleanupExpired() {
let now = Date()
// Can't use date comparison directly in predicate with non-nil check
// Fetch all and filter
let descriptor = FetchDescriptor<CachedGameScore>()
do {
let allCached = try modelContext.fetch(descriptor)
var deletedCount = 0
for entry in allCached {
if let expiresAt = entry.expiresAt, expiresAt < now {
modelContext.delete(entry)
deletedCount += 1
}
}
if deletedCount > 0 {
try modelContext.save()
}
} catch {
// Cleanup failed, will try again later
}
}
/// Get cache statistics
func getCacheStats() -> CacheStats {
let descriptor = FetchDescriptor<CachedGameScore>()
do {
let all = try modelContext.fetch(descriptor)
let now = Date()
var withScores = 0
var withoutScores = 0
var expired = 0
var bySport: [Sport: Int] = [:]
for entry in all {
// Count by sport
if let sport = entry.sportEnum {
bySport[sport, default: 0] += 1
}
// Count with/without scores
if entry.homeScore != nil && entry.awayScore != nil {
withScores += 1
} else {
withoutScores += 1
}
// Count expired
if let expiresAt = entry.expiresAt, expiresAt < now {
expired += 1
}
}
return CacheStats(
totalEntries: all.count,
entriesWithScores: withScores,
entriesWithoutScores: withoutScores,
expiredEntries: expired,
entriesBySport: bySport
)
} catch {
return CacheStats(
totalEntries: 0,
entriesWithScores: 0,
entriesWithoutScores: 0,
expiredEntries: 0,
entriesBySport: [:]
)
}
}
// MARK: - Private Helpers
private func calculateExpiration(for gameDate: Date) -> Date? {
let now = Date()
let gameAge = now.timeIntervalSince(gameDate)
if gameAge > Self.historicalAgeThreshold {
// Historical games never expire
return nil
} else {
// Recent games expire after 24 hours
return now.addingTimeInterval(Self.recentGameCacheDuration)
}
}
}
// MARK: - Cache Statistics
struct CacheStats {
let totalEntries: Int
let entriesWithScores: Int
let entriesWithoutScores: Int
let expiredEntries: Int
let entriesBySport: [Sport: Int]
}

View File

@@ -0,0 +1,273 @@
//
// StadiumIdentityService.swift
// SportsTime
//
// Service for resolving stadium identities across renames and aliases.
// Wraps CanonicalStadium lookups from SwiftData.
//
import Foundation
import SwiftData
// MARK: - Stadium Identity Service
/// Resolves stadium identities to canonical IDs, handling renames and aliases.
/// Example: "SBC Park", "AT&T Park", and "Oracle Park" all resolve to the same canonical ID.
actor StadiumIdentityService {
// MARK: - Singleton
static let shared = StadiumIdentityService()
// MARK: - Properties
private var modelContainer: ModelContainer?
// Cache for performance
private var uuidToCanonicalId: [UUID: String] = [:]
private var canonicalIdToUUID: [String: UUID] = [:]
private var nameToCanonicalId: [String: String] = [:]
// MARK: - Initialization
private init() {}
// MARK: - Configuration
/// Configure the service with a model container
func configure(with container: ModelContainer) {
self.modelContainer = container
invalidateCache()
}
// MARK: - Public Methods
/// Get the canonical ID for a stadium UUID
/// Returns the same canonicalId for stadiums that are the same physical location
func canonicalId(for stadiumUUID: UUID) async throws -> String? {
// Check cache first
if let cached = uuidToCanonicalId[stadiumUUID] {
return cached
}
guard let container = modelContainer else {
return nil
}
let context = ModelContext(container)
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.uuid == stadiumUUID
}
)
guard let stadium = try context.fetch(descriptor).first else {
return nil
}
// Cache the result
uuidToCanonicalId[stadiumUUID] = stadium.canonicalId
canonicalIdToUUID[stadium.canonicalId] = stadium.uuid
return stadium.canonicalId
}
/// Get the canonical ID for a stadium name (searches aliases too)
func canonicalId(forName name: String) async throws -> String? {
let lowercasedName = name.lowercased()
// Check cache first
if let cached = nameToCanonicalId[lowercasedName] {
return cached
}
guard let container = modelContainer else {
return nil
}
let context = ModelContext(container)
// First check stadium aliases
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { alias in
alias.aliasName == lowercasedName
}
)
if let alias = try context.fetch(aliasDescriptor).first {
nameToCanonicalId[lowercasedName] = alias.stadiumCanonicalId
return alias.stadiumCanonicalId
}
// Fall back to direct stadium name match
let stadiumDescriptor = FetchDescriptor<CanonicalStadium>()
let stadiums = try context.fetch(stadiumDescriptor)
// Case-insensitive match on stadium name
if let stadium = stadiums.first(where: { $0.name.lowercased() == lowercasedName }) {
nameToCanonicalId[lowercasedName] = stadium.canonicalId
return stadium.canonicalId
}
return nil
}
/// Check if two stadium UUIDs represent the same physical stadium
func isSameStadium(_ id1: UUID, _ id2: UUID) async throws -> Bool {
guard let canonicalId1 = try await canonicalId(for: id1),
let canonicalId2 = try await canonicalId(for: id2) else {
// If we can't resolve, fall back to direct comparison
return id1 == id2
}
return canonicalId1 == canonicalId2
}
/// Get the current UUID for a canonical stadium ID
func currentUUID(forCanonicalId canonicalId: String) async throws -> UUID? {
// Check cache first
if let cached = canonicalIdToUUID[canonicalId] {
return cached
}
guard let container = modelContainer else {
return nil
}
let context = ModelContext(container)
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil
}
)
guard let stadium = try context.fetch(descriptor).first else {
return nil
}
// Cache the result
canonicalIdToUUID[canonicalId] = stadium.uuid
uuidToCanonicalId[stadium.uuid] = stadium.canonicalId
return stadium.uuid
}
/// Get the current name for a canonical stadium ID
func currentName(forCanonicalId canonicalId: String) async throws -> String? {
guard let container = modelContainer else {
return nil
}
let context = ModelContext(container)
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.canonicalId == canonicalId && stadium.deprecatedAt == nil
}
)
guard let stadium = try context.fetch(descriptor).first else {
return nil
}
return stadium.name
}
/// Get all historical names for a stadium
func allNames(forCanonicalId canonicalId: String) async throws -> [String] {
guard let container = modelContainer else {
return []
}
let context = ModelContext(container)
// Get aliases
let aliasDescriptor = FetchDescriptor<StadiumAlias>(
predicate: #Predicate<StadiumAlias> { alias in
alias.stadiumCanonicalId == canonicalId
}
)
let aliases = try context.fetch(aliasDescriptor)
var names = aliases.map { $0.aliasName }
// Add current name
if let currentName = try await currentName(forCanonicalId: canonicalId) {
if !names.contains(currentName.lowercased()) {
names.append(currentName)
}
}
return names
}
/// Find stadium by approximate location (for photo import)
func findStadium(near latitude: Double, longitude: Double, radiusMeters: Double = 5000) async throws -> CanonicalStadium? {
guard let container = modelContainer else {
return nil
}
let context = ModelContext(container)
// Fetch all active stadiums and filter by distance
let descriptor = FetchDescriptor<CanonicalStadium>(
predicate: #Predicate<CanonicalStadium> { stadium in
stadium.deprecatedAt == nil
}
)
let stadiums = try context.fetch(descriptor)
// Calculate approximate degree ranges for the radius
// At equator: 1 degree 111km, so radiusMeters / 111000 gives degrees
let degreeDelta = radiusMeters / 111000.0
let nearbyStadiums = stadiums.filter { stadium in
abs(stadium.latitude - latitude) <= degreeDelta &&
abs(stadium.longitude - longitude) <= degreeDelta * 1.5 // Account for longitude compression at higher latitudes
}
// If multiple stadiums nearby, find the closest
guard !nearbyStadiums.isEmpty else {
return nil
}
if nearbyStadiums.count == 1 {
return nearbyStadiums.first
}
// Calculate actual distances for the nearby stadiums
return nearbyStadiums.min { s1, s2 in
let d1 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s1.latitude, lon2: s1.longitude)
let d2 = haversineDistance(lat1: latitude, lon1: longitude, lat2: s2.latitude, lon2: s2.longitude)
return d1 < d2
}
}
// MARK: - Cache Management
/// Invalidate all caches (call after sync)
func invalidateCache() {
uuidToCanonicalId.removeAll()
canonicalIdToUUID.removeAll()
nameToCanonicalId.removeAll()
}
// MARK: - Private Helpers
/// Calculate distance between two coordinates using Haversine formula
nonisolated private func haversineDistance(lat1: Double, lon1: Double, lat2: Double, lon2: Double) -> Double {
let R = 6371000.0 // Earth's radius in meters
let phi1 = lat1 * .pi / 180
let phi2 = lat2 * .pi / 180
let deltaPhi = (lat2 - lat1) * .pi / 180
let deltaLambda = (lon2 - lon1) * .pi / 180
let a = sin(deltaPhi / 2) * sin(deltaPhi / 2) +
cos(phi1) * cos(phi2) * sin(deltaLambda / 2) * sin(deltaLambda / 2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return R * c
}
}

View File

@@ -0,0 +1,348 @@
//
// StadiumProximityMatcher.swift
// SportsTime
//
// Service for matching GPS coordinates to nearby stadiums.
//
import Foundation
import CoreLocation
// MARK: - Match Confidence
enum MatchConfidence: Sendable {
case high // < 500m from stadium center
case medium // 500m - 2km
case low // 2km - 5km
case none // > 5km or no coordinates
nonisolated var description: String {
switch self {
case .high: return "High (within 500m)"
case .medium: return "Medium (500m - 2km)"
case .low: return "Low (2km - 5km)"
case .none: return "No match"
}
}
/// Should auto-select this match without user confirmation?
nonisolated var shouldAutoSelect: Bool {
switch self {
case .high: return true
default: return false
}
}
}
// Explicit nonisolated Equatable and Comparable conformance
extension MatchConfidence: Equatable {
nonisolated static func == (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool {
switch (lhs, rhs) {
case (.high, .high), (.medium, .medium), (.low, .low), (.none, .none):
return true
default:
return false
}
}
}
extension MatchConfidence: Comparable {
nonisolated static func < (lhs: MatchConfidence, rhs: MatchConfidence) -> Bool {
let order: [MatchConfidence] = [.none, .low, .medium, .high]
guard let lhsIndex = order.firstIndex(of: lhs),
let rhsIndex = order.firstIndex(of: rhs) else {
return false
}
return lhsIndex < rhsIndex
}
}
// MARK: - Stadium Match
struct StadiumMatch: Identifiable, Sendable {
let id: UUID
let stadium: Stadium
let distance: CLLocationDistance
let confidence: MatchConfidence
init(stadium: Stadium, distance: CLLocationDistance) {
self.id = stadium.id
self.stadium = stadium
self.distance = distance
self.confidence = Self.calculateConfidence(for: distance)
}
var formattedDistance: String {
if distance < 1000 {
return String(format: "%.0fm away", distance)
} else {
return String(format: "%.1f km away", distance / 1000)
}
}
private static func calculateConfidence(for distance: CLLocationDistance) -> MatchConfidence {
switch distance {
case 0..<500:
return .high
case 500..<2000:
return .medium
case 2000..<5000:
return .low
default:
return .none
}
}
}
// MARK: - Temporal Confidence
enum TemporalConfidence: Sendable {
case exactDay // Same local date as game
case adjacentDay // ±1 day (tailgating, next morning)
case outOfRange // >1 day difference
nonisolated var description: String {
switch self {
case .exactDay: return "Same day"
case .adjacentDay: return "Adjacent day (±1)"
case .outOfRange: return "Out of range"
}
}
}
extension TemporalConfidence: Equatable {
nonisolated static func == (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool {
switch (lhs, rhs) {
case (.exactDay, .exactDay), (.adjacentDay, .adjacentDay), (.outOfRange, .outOfRange):
return true
default:
return false
}
}
}
extension TemporalConfidence: Comparable {
nonisolated static func < (lhs: TemporalConfidence, rhs: TemporalConfidence) -> Bool {
let order: [TemporalConfidence] = [.outOfRange, .adjacentDay, .exactDay]
guard let lhsIndex = order.firstIndex(of: lhs),
let rhsIndex = order.firstIndex(of: rhs) else {
return false
}
return lhsIndex < rhsIndex
}
}
// MARK: - Combined Confidence
enum CombinedConfidence: Sendable {
case autoSelect // High spatial + exactDay auto-select
case userConfirm // Medium spatial OR adjacentDay user confirms
case manualOnly // Low spatial OR outOfRange manual entry
nonisolated var description: String {
switch self {
case .autoSelect: return "Auto-select"
case .userConfirm: return "Needs confirmation"
case .manualOnly: return "Manual entry required"
}
}
nonisolated static func combine(spatial: MatchConfidence, temporal: TemporalConfidence) -> CombinedConfidence {
// Low spatial or out of range manual only
switch spatial {
case .low, .none:
return .manualOnly
default:
break
}
switch temporal {
case .outOfRange:
return .manualOnly
default:
break
}
// High spatial + exact day auto-select
switch (spatial, temporal) {
case (.high, .exactDay):
return .autoSelect
default:
break
}
// Everything else needs user confirmation
return .userConfirm
}
}
extension CombinedConfidence: Equatable {
nonisolated static func == (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool {
switch (lhs, rhs) {
case (.autoSelect, .autoSelect), (.userConfirm, .userConfirm), (.manualOnly, .manualOnly):
return true
default:
return false
}
}
}
extension CombinedConfidence: Comparable {
nonisolated static func < (lhs: CombinedConfidence, rhs: CombinedConfidence) -> Bool {
let order: [CombinedConfidence] = [.manualOnly, .userConfirm, .autoSelect]
guard let lhsIndex = order.firstIndex(of: lhs),
let rhsIndex = order.firstIndex(of: rhs) else {
return false
}
return lhsIndex < rhsIndex
}
}
// MARK: - Photo Match Confidence
struct PhotoMatchConfidence: Sendable {
let spatial: MatchConfidence
let temporal: TemporalConfidence
let combined: CombinedConfidence
nonisolated init(spatial: MatchConfidence, temporal: TemporalConfidence) {
self.spatial = spatial
self.temporal = temporal
self.combined = CombinedConfidence.combine(spatial: spatial, temporal: temporal)
}
}
// MARK: - Stadium Proximity Matcher
@MainActor
final class StadiumProximityMatcher {
static let shared = StadiumProximityMatcher()
// Configuration constants
static let highConfidenceRadius: CLLocationDistance = 500 // 500m
static let mediumConfidenceRadius: CLLocationDistance = 2000 // 2km
static let searchRadius: CLLocationDistance = 5000 // 5km default
static let dateToleranceDays: Int = 1 // ±1 day for timezone/tailgating
private let dataProvider = AppDataProvider.shared
private init() {}
// MARK: - Stadium Matching
/// Find stadiums within radius of coordinates
func findNearbyStadiums(
coordinates: CLLocationCoordinate2D,
radius: CLLocationDistance = StadiumProximityMatcher.searchRadius,
sport: Sport? = nil
) -> [StadiumMatch] {
let photoLocation = CLLocation(latitude: coordinates.latitude, longitude: coordinates.longitude)
var stadiums = dataProvider.stadiums
// Filter by sport if specified
if let sport = sport {
let sportTeams = dataProvider.teams.filter { $0.sport == sport }
let stadiumIds = Set(sportTeams.map { $0.stadiumId })
stadiums = stadiums.filter { stadiumIds.contains($0.id) }
}
// Calculate distances and filter by radius
var matches: [StadiumMatch] = []
for stadium in stadiums {
let stadiumLocation = CLLocation(latitude: stadium.latitude, longitude: stadium.longitude)
let distance = photoLocation.distance(from: stadiumLocation)
if distance <= radius {
matches.append(StadiumMatch(stadium: stadium, distance: distance))
}
}
// Sort by distance (closest first)
return matches.sorted { $0.distance < $1.distance }
}
/// Find best matching stadium (single result)
func findBestMatch(
coordinates: CLLocationCoordinate2D,
sport: Sport? = nil
) -> StadiumMatch? {
let matches = findNearbyStadiums(coordinates: coordinates, sport: sport)
return matches.first
}
/// Check if coordinates are near any stadium
func isNearStadium(
coordinates: CLLocationCoordinate2D,
radius: CLLocationDistance = StadiumProximityMatcher.searchRadius
) -> Bool {
let matches = findNearbyStadiums(coordinates: coordinates, radius: radius)
return !matches.isEmpty
}
// MARK: - Temporal Matching
/// Calculate temporal confidence between photo date and game date
nonisolated func calculateTemporalConfidence(photoDate: Date, gameDate: Date) -> TemporalConfidence {
let calendar = Calendar.current
// Normalize to day boundaries
let photoDay = calendar.startOfDay(for: photoDate)
let gameDay = calendar.startOfDay(for: gameDate)
let daysDifference = abs(calendar.dateComponents([.day], from: photoDay, to: gameDay).day ?? Int.max)
switch daysDifference {
case 0:
return .exactDay
case 1:
return .adjacentDay
default:
return .outOfRange
}
}
/// Calculate combined confidence for a photo-stadium-game match
nonisolated func calculateMatchConfidence(
stadiumMatch: StadiumMatch,
photoDate: Date?,
gameDate: Date?
) -> PhotoMatchConfidence {
let spatial = stadiumMatch.confidence
let temporal: TemporalConfidence
if let photoDate = photoDate, let gameDate = gameDate {
temporal = calculateTemporalConfidence(photoDate: photoDate, gameDate: gameDate)
} else {
// Missing date information
temporal = .outOfRange
}
return PhotoMatchConfidence(spatial: spatial, temporal: temporal)
}
}
// MARK: - Batch Processing
extension StadiumProximityMatcher {
/// Find matches for multiple photos
func findMatchesForPhotos(
_ metadata: [PhotoMetadata],
sport: Sport? = nil
) -> [(metadata: PhotoMetadata, matches: [StadiumMatch])] {
var results: [(metadata: PhotoMetadata, matches: [StadiumMatch])] = []
for photo in metadata {
if let coordinates = photo.coordinates {
let matches = findNearbyStadiums(coordinates: coordinates, sport: sport)
results.append((metadata: photo, matches: matches))
} else {
// No coordinates - empty matches
results.append((metadata: photo, matches: []))
}
}
return results
}
}

View File

@@ -194,6 +194,7 @@ actor StubDataProvider: DataProvider {
latitude: json.latitude,
longitude: json.longitude,
capacity: json.capacity,
sport: parseSport(json.sport),
yearOpened: json.year_opened
)
}

View File

@@ -265,13 +265,17 @@ final class SuggestedTripsGenerator {
// Build richGames dictionary
let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums)
// Compute sports from games actually in the trip (not all selectedGames)
let gameIdsInTrip = Set(trip.stops.flatMap { $0.games })
let actualSports = Set(gameIdsInTrip.compactMap { richGames[$0]?.game.sport })
return SuggestedTrip(
id: UUID(),
region: region,
isSingleSport: singleSport,
isSingleSport: actualSports.count == 1,
trip: trip,
richGames: richGames,
sports: sports
sports: actualSports.isEmpty ? sports : actualSports
)
case .failure:
@@ -339,6 +343,10 @@ final class SuggestedTripsGenerator {
guard selectedGames.count >= 4 else { return nil }
// Ensure enough unique cities for a true cross-country trip
let uniqueCities = Set(selectedGames.compactMap { stadiums[$0.stadiumId]?.city })
guard uniqueCities.count >= 3 else { return nil }
// Calculate trip dates
guard let firstGame = selectedGames.first,
let lastGame = selectedGames.last else { return nil }
@@ -377,13 +385,29 @@ final class SuggestedTripsGenerator {
// Build richGames dictionary
let richGames = buildRichGames(from: selectedGames, teams: teams, stadiums: stadiums)
// Validate the final trip meets cross-country requirements:
// - At least 4 stops (cities)
// - At least 2 different regions
guard trip.stops.count >= 4 else { return nil }
let stopsWithRegions = trip.stops.compactMap { stop -> Region? in
guard let stadium = stadiums.values.first(where: { $0.city == stop.city }) else { return nil }
return stadium.region
}
let uniqueRegions = Set(stopsWithRegions)
guard uniqueRegions.count >= 2 else { return nil }
// Compute sports from games actually in the trip (not all selectedGames)
let gameIdsInTrip = Set(trip.stops.flatMap { $0.games })
let actualSports = Set(gameIdsInTrip.compactMap { richGames[$0]?.game.sport })
return SuggestedTrip(
id: UUID(),
region: .crossCountry,
isSingleSport: sports.count == 1,
isSingleSport: actualSports.count == 1,
trip: trip,
richGames: richGames,
sports: sports
sports: actualSports.isEmpty ? sports : actualSports
)
case .failure:

View File

@@ -0,0 +1,410 @@
//
// VisitPhotoService.swift
// SportsTime
//
// Manages visit photos with CloudKit sync for backup.
// Thumbnails stored locally in SwiftData for fast loading.
// Full images stored in CloudKit private database.
//
import Foundation
import CloudKit
import SwiftData
import UIKit
// MARK: - Photo Service Errors
enum PhotoServiceError: Error, LocalizedError {
case notSignedIn
case uploadFailed(String)
case downloadFailed(String)
case thumbnailGenerationFailed
case invalidImage
case assetNotFound
case quotaExceeded
var errorDescription: String? {
switch self {
case .notSignedIn:
return "Please sign in to iCloud to sync photos"
case .uploadFailed(let message):
return "Upload failed: \(message)"
case .downloadFailed(let message):
return "Download failed: \(message)"
case .thumbnailGenerationFailed:
return "Could not generate thumbnail"
case .invalidImage:
return "Invalid image data"
case .assetNotFound:
return "Photo not found in cloud storage"
case .quotaExceeded:
return "iCloud storage quota exceeded"
}
}
}
// MARK: - Visit Photo Service
@MainActor
final class VisitPhotoService {
// MARK: - Properties
private let modelContext: ModelContext
private let container: CKContainer
private let privateDatabase: CKDatabase
// Configuration
private static let thumbnailSize = CGSize(width: 200, height: 200)
private static let compressionQuality: CGFloat = 0.7
private static let recordType = "VisitPhoto"
// MARK: - Initialization
init(modelContext: ModelContext) {
self.modelContext = modelContext
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
self.privateDatabase = container.privateCloudDatabase
}
// MARK: - Public API
/// Add a photo to a visit
/// - Parameters:
/// - visit: The visit to add the photo to
/// - image: The UIImage to add
/// - caption: Optional caption for the photo
/// - Returns: The created photo metadata
func addPhoto(to visit: StadiumVisit, image: UIImage, caption: String? = nil) async throws -> VisitPhotoMetadata {
// Generate thumbnail
guard let thumbnail = generateThumbnail(from: image) else {
throw PhotoServiceError.thumbnailGenerationFailed
}
guard let thumbnailData = thumbnail.jpegData(compressionQuality: Self.compressionQuality) else {
throw PhotoServiceError.thumbnailGenerationFailed
}
// Get current photo count for order index
let orderIndex = visit.photoMetadata?.count ?? 0
// Create metadata record
let metadata = VisitPhotoMetadata(
visitId: visit.id,
cloudKitAssetId: nil,
thumbnailData: thumbnailData,
caption: caption,
orderIndex: orderIndex,
uploadStatus: .pending
)
// Add to visit
if visit.photoMetadata == nil {
visit.photoMetadata = []
}
visit.photoMetadata?.append(metadata)
modelContext.insert(metadata)
try modelContext.save()
// Queue background upload
Task.detached { [weak self] in
await self?.uploadPhoto(metadata: metadata, image: image)
}
return metadata
}
/// Fetch full-resolution image for a photo
/// - Parameter metadata: The photo metadata
/// - Returns: The full-resolution UIImage
func fetchFullImage(for metadata: VisitPhotoMetadata) async throws -> UIImage {
guard let assetId = metadata.cloudKitAssetId else {
throw PhotoServiceError.assetNotFound
}
let recordID = CKRecord.ID(recordName: assetId)
do {
let record = try await privateDatabase.record(for: recordID)
guard let asset = record["imageAsset"] as? CKAsset,
let fileURL = asset.fileURL,
let data = try? Data(contentsOf: fileURL),
let image = UIImage(data: data) else {
throw PhotoServiceError.downloadFailed("Could not read image data")
}
return image
} catch let error as CKError {
throw mapCloudKitError(error)
}
}
/// Delete a photo from visit and CloudKit
/// - Parameter metadata: The photo metadata to delete
func deletePhoto(_ metadata: VisitPhotoMetadata) async throws {
// Delete from CloudKit if uploaded
if let assetId = metadata.cloudKitAssetId {
let recordID = CKRecord.ID(recordName: assetId)
do {
try await privateDatabase.deleteRecord(withID: recordID)
} catch {
// Continue with local deletion even if CloudKit fails
}
}
// Delete from SwiftData
modelContext.delete(metadata)
try modelContext.save()
}
/// Retry uploading failed photos
func retryFailedUploads() async {
let descriptor = FetchDescriptor<VisitPhotoMetadata>(
predicate: #Predicate { $0.uploadStatusRaw == "failed" || $0.uploadStatusRaw == "pending" }
)
do {
let pendingPhotos = try modelContext.fetch(descriptor)
for metadata in pendingPhotos {
// We can't upload without the original image
// Mark as failed permanently if no thumbnail
if metadata.thumbnailData == nil {
metadata.uploadStatus = .failed
}
}
try modelContext.save()
} catch {
// Silently fail - will retry on next launch
}
}
/// Get upload status summary
func getUploadStatus() -> (pending: Int, uploaded: Int, failed: Int) {
let descriptor = FetchDescriptor<VisitPhotoMetadata>()
do {
let all = try modelContext.fetch(descriptor)
let pending = all.filter { $0.uploadStatus == .pending }.count
let uploaded = all.filter { $0.uploadStatus == .uploaded }.count
let failed = all.filter { $0.uploadStatus == .failed }.count
return (pending, uploaded, failed)
} catch {
return (0, 0, 0)
}
}
/// Check if CloudKit is available for photo sync
func isCloudKitAvailable() async -> Bool {
do {
let status = try await container.accountStatus()
return status == .available
} catch {
return false
}
}
// MARK: - Private Methods
private func uploadPhoto(metadata: VisitPhotoMetadata, image: UIImage) async {
guard let imageData = image.jpegData(compressionQuality: Self.compressionQuality) else {
await MainActor.run {
metadata.uploadStatus = .failed
try? modelContext.save()
}
return
}
// Check CloudKit availability
do {
let status = try await container.accountStatus()
guard status == .available else {
await MainActor.run {
metadata.uploadStatus = .failed
try? modelContext.save()
}
return
}
} catch {
await MainActor.run {
metadata.uploadStatus = .failed
try? modelContext.save()
}
return
}
// Create CloudKit record
let recordID = CKRecord.ID(recordName: metadata.id.uuidString)
let record = CKRecord(recordType: Self.recordType, recordID: recordID)
// Write image to temporary file for CKAsset
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("jpg")
do {
try imageData.write(to: tempURL)
let asset = CKAsset(fileURL: tempURL)
record["imageAsset"] = asset
record["visitId"] = metadata.visitId.uuidString
record["caption"] = metadata.caption
record["orderIndex"] = metadata.orderIndex as CKRecordValue
// Upload to CloudKit
let savedRecord = try await privateDatabase.save(record)
// Clean up temp file
try? FileManager.default.removeItem(at: tempURL)
// Update metadata
await MainActor.run {
metadata.cloudKitAssetId = savedRecord.recordID.recordName
metadata.uploadStatus = .uploaded
try? modelContext.save()
}
} catch let error as CKError {
// Clean up temp file
try? FileManager.default.removeItem(at: tempURL)
await MainActor.run {
metadata.uploadStatus = .failed
try? modelContext.save()
}
} catch {
// Clean up temp file
try? FileManager.default.removeItem(at: tempURL)
await MainActor.run {
metadata.uploadStatus = .failed
try? modelContext.save()
}
}
}
private func generateThumbnail(from image: UIImage) -> UIImage? {
let size = Self.thumbnailSize
let aspectRatio = image.size.width / image.size.height
let targetSize: CGSize
if aspectRatio > 1 {
// Landscape
targetSize = CGSize(width: size.width, height: size.width / aspectRatio)
} else {
// Portrait or square
targetSize = CGSize(width: size.height * aspectRatio, height: size.height)
}
let renderer = UIGraphicsImageRenderer(size: targetSize)
return renderer.image { context in
image.draw(in: CGRect(origin: .zero, size: targetSize))
}
}
private func mapCloudKitError(_ error: CKError) -> PhotoServiceError {
switch error.code {
case .notAuthenticated:
return .notSignedIn
case .quotaExceeded:
return .quotaExceeded
case .unknownItem:
return .assetNotFound
default:
return .downloadFailed(error.localizedDescription)
}
}
}
// MARK: - Photo Gallery View Model
@Observable
@MainActor
final class PhotoGalleryViewModel {
var photos: [VisitPhotoMetadata] = []
var selectedPhoto: VisitPhotoMetadata?
var fullResolutionImage: UIImage?
var isLoadingFullImage = false
var error: PhotoServiceError?
private let photoService: VisitPhotoService
private let visit: StadiumVisit
init(visit: StadiumVisit, modelContext: ModelContext) {
self.visit = visit
self.photoService = VisitPhotoService(modelContext: modelContext)
loadPhotos()
}
func loadPhotos() {
photos = (visit.photoMetadata ?? []).sorted { $0.orderIndex < $1.orderIndex }
}
func addPhoto(_ image: UIImage, caption: String? = nil) async {
do {
let metadata = try await photoService.addPhoto(to: visit, image: image, caption: caption)
photos.append(metadata)
photos.sort { $0.orderIndex < $1.orderIndex }
} catch let error as PhotoServiceError {
self.error = error
} catch {
self.error = .uploadFailed(error.localizedDescription)
}
}
func selectPhoto(_ metadata: VisitPhotoMetadata) {
selectedPhoto = metadata
loadFullResolution(for: metadata)
}
func loadFullResolution(for metadata: VisitPhotoMetadata) {
guard metadata.cloudKitAssetId != nil else {
// Photo not uploaded yet, use thumbnail
if let data = metadata.thumbnailData {
fullResolutionImage = UIImage(data: data)
}
return
}
isLoadingFullImage = true
Task {
do {
let image = try await photoService.fetchFullImage(for: metadata)
fullResolutionImage = image
} catch let error as PhotoServiceError {
self.error = error
// Fall back to thumbnail
if let data = metadata.thumbnailData {
fullResolutionImage = UIImage(data: data)
}
} catch {
self.error = .downloadFailed(error.localizedDescription)
}
isLoadingFullImage = false
}
}
func deletePhoto(_ metadata: VisitPhotoMetadata) async {
do {
try await photoService.deletePhoto(metadata)
photos.removeAll { $0.id == metadata.id }
if selectedPhoto?.id == metadata.id {
selectedPhoto = nil
fullResolutionImage = nil
}
} catch let error as PhotoServiceError {
self.error = error
} catch {
self.error = .uploadFailed(error.localizedDescription)
}
}
func clearError() {
error = nil
}
}

View File

@@ -103,6 +103,75 @@ struct AnimatedRouteGraphic: View {
}
}
// MARK: - Themed Spinner
/// A custom animated spinner matching the app's visual style
struct ThemedSpinner: View {
var size: CGFloat = 40
var lineWidth: CGFloat = 4
@State private var rotation: Double = 0
@State private var trimEnd: CGFloat = 0.6
var body: some View {
ZStack {
// Background track
Circle()
.stroke(Theme.warmOrange.opacity(0.15), lineWidth: lineWidth)
// Animated arc
Circle()
.trim(from: 0, to: trimEnd)
.stroke(
AngularGradient(
gradient: Gradient(colors: [Theme.warmOrange, Theme.routeGold, Theme.warmOrange.opacity(0.3)]),
center: .center,
startAngle: .degrees(0),
endAngle: .degrees(360)
),
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.rotationEffect(.degrees(rotation))
// Center glow dot
Circle()
.fill(Theme.warmOrange.opacity(0.2))
.frame(width: size * 0.3, height: size * 0.3)
.blur(radius: 4)
}
.frame(width: size, height: size)
.onAppear {
withAnimation(.linear(duration: 1).repeatForever(autoreverses: false)) {
rotation = 360
}
withAnimation(.easeInOut(duration: 0.8).repeatForever(autoreverses: true)) {
trimEnd = 0.8
}
}
}
}
/// Compact themed spinner for inline use
struct ThemedSpinnerCompact: View {
var size: CGFloat = 20
var color: Color = Theme.warmOrange
@State private var rotation: Double = 0
var body: some View {
Circle()
.trim(from: 0, to: 0.7)
.stroke(color, style: StrokeStyle(lineWidth: size > 16 ? 2.5 : 2, lineCap: .round))
.frame(width: size, height: size)
.rotationEffect(.degrees(rotation))
.onAppear {
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
rotation = 360
}
}
}
}
// MARK: - Pulsing Dot
struct PulsingDot: View {
@@ -188,10 +257,8 @@ struct PlanningProgressView: View {
var body: some View {
VStack(spacing: 24) {
// Simple spinner
ProgressView()
.scaleEffect(1.5)
.tint(Theme.warmOrange)
// Themed spinner
ThemedSpinner(size: 56, lineWidth: 5)
// Current step text
Text(steps[currentStep])
@@ -284,8 +351,96 @@ struct EmptyStateView: View {
}
}
// MARK: - Loading Overlay
/// A modal loading overlay with progress indication
/// Reusable pattern from PDF export overlay
struct LoadingOverlay: View {
let message: String
var detail: String?
var progress: Double?
var icon: String = "hourglass"
@Environment(\.colorScheme) private var colorScheme
var body: some View {
ZStack {
// Background dimmer
Color.black.opacity(0.6)
.ignoresSafeArea()
// Progress card
VStack(spacing: Theme.Spacing.lg) {
// Progress ring or spinner
ZStack {
Circle()
.stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 8)
.frame(width: 80, height: 80)
if let progress = progress {
Circle()
.trim(from: 0, to: progress)
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
.frame(width: 80, height: 80)
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.3), value: progress)
} else {
ThemedSpinner(size: 48, lineWidth: 5)
}
Image(systemName: icon)
.font(.system(size: 24))
.foregroundStyle(Theme.warmOrange)
.opacity(progress != nil ? 1 : 0)
}
VStack(spacing: Theme.Spacing.xs) {
Text(message)
.font(.system(size: Theme.FontSize.cardTitle, weight: .semibold))
.foregroundStyle(Theme.textPrimary(colorScheme))
if let detail = detail {
Text(detail)
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textSecondary(colorScheme))
.multilineTextAlignment(.center)
}
if let progress = progress {
Text("\(Int(progress * 100))%")
.font(.system(size: Theme.FontSize.micro, weight: .medium, design: .monospaced))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
}
.padding(Theme.Spacing.xl)
.background(Theme.cardBackground(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
.shadow(color: .black.opacity(0.3), radius: 20, y: 10)
}
.transition(.opacity)
}
}
// MARK: - Preview
#Preview("Themed Spinners") {
VStack(spacing: 40) {
ThemedSpinner(size: 60, lineWidth: 5)
ThemedSpinner(size: 40)
ThemedSpinnerCompact()
HStack(spacing: 20) {
ThemedSpinnerCompact(size: 16)
Text("Loading...")
}
}
.padding(40)
.themedBackground()
}
#Preview("Animated Components") {
VStack(spacing: 40) {
AnimatedRouteGraphic()
@@ -306,3 +461,25 @@ struct EmptyStateView: View {
.padding()
.themedBackground()
}
#Preview("Loading Overlay") {
ZStack {
Color.gray
LoadingOverlay(
message: "Planning Your Trip",
detail: "Finding the best route..."
)
}
}
#Preview("Loading Overlay with Progress") {
ZStack {
Color.gray
LoadingOverlay(
message: "Creating PDF",
detail: "Processing images...",
progress: 0.65,
icon: "doc.fill"
)
}
}

View File

@@ -0,0 +1,598 @@
//
// ProgressCardGenerator.swift
// SportsTime
//
// Generates shareable progress cards for social media.
// Cards include progress ring, stats, optional username, and app branding.
//
import SwiftUI
import UIKit
import MapKit
// MARK: - Progress Card Generator
@MainActor
final class ProgressCardGenerator {
// Card dimensions (Instagram story size)
private static let cardSize = CGSize(width: 1080, height: 1920)
private static let mapSnapshotSize = CGSize(width: 1000, height: 500)
// MARK: - Generate Card
/// Generate a shareable progress card image
/// - Parameters:
/// - progress: The league progress data
/// - options: Card generation options
/// - Returns: The generated UIImage
func generateCard(
progress: LeagueProgress,
options: ProgressCardOptions = ProgressCardOptions()
) async throws -> UIImage {
// Generate map snapshot if needed
var mapSnapshot: UIImage?
if options.includeMapSnapshot {
mapSnapshot = await generateMapSnapshot(
visited: progress.stadiumsVisited,
remaining: progress.stadiumsRemaining
)
}
// Render SwiftUI view to image
let cardView = ProgressCardView(
progress: progress,
options: options,
mapSnapshot: mapSnapshot
)
let renderer = ImageRenderer(content: cardView)
renderer.scale = 3.0 // High resolution
guard let image = renderer.uiImage else {
throw CardGeneratorError.renderingFailed
}
return image
}
/// Generate a map snapshot showing visited/unvisited stadiums
/// - Parameters:
/// - visited: Stadiums that have been visited
/// - remaining: Stadiums not yet visited
/// - Returns: The map snapshot image
func generateMapSnapshot(
visited: [Stadium],
remaining: [Stadium]
) async -> UIImage? {
let allStadiums = visited + remaining
guard !allStadiums.isEmpty else { return nil }
// Calculate region to show all stadiums
let coordinates = allStadiums.map {
CLLocationCoordinate2D(latitude: $0.latitude, longitude: $0.longitude)
}
let minLat = coordinates.map(\.latitude).min() ?? 0
let maxLat = coordinates.map(\.latitude).max() ?? 0
let minLon = coordinates.map(\.longitude).min() ?? 0
let maxLon = coordinates.map(\.longitude).max() ?? 0
let center = CLLocationCoordinate2D(
latitude: (minLat + maxLat) / 2,
longitude: (minLon + maxLon) / 2
)
let span = MKCoordinateSpan(
latitudeDelta: (maxLat - minLat) * 1.3,
longitudeDelta: (maxLon - minLon) * 1.3
)
let region = MKCoordinateRegion(center: center, span: span)
// Create snapshot options
let options = MKMapSnapshotter.Options()
options.region = region
options.size = Self.mapSnapshotSize
options.mapType = .mutedStandard
let snapshotter = MKMapSnapshotter(options: options)
do {
let snapshot = try await snapshotter.start()
// Draw annotations on snapshot
let image = UIGraphicsImageRenderer(size: Self.mapSnapshotSize).image { context in
snapshot.image.draw(at: .zero)
// Draw stadium markers
for stadium in remaining {
let point = snapshot.point(for: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
))
drawMarker(at: point, color: .gray, context: context.cgContext)
}
for stadium in visited {
let point = snapshot.point(for: CLLocationCoordinate2D(
latitude: stadium.latitude,
longitude: stadium.longitude
))
drawMarker(at: point, color: UIColor(Theme.warmOrange), context: context.cgContext)
}
}
return image
} catch {
return nil
}
}
private func drawMarker(at point: CGPoint, color: UIColor, context: CGContext) {
let markerSize: CGFloat = 16
context.setFillColor(color.cgColor)
context.fillEllipse(in: CGRect(
x: point.x - markerSize / 2,
y: point.y - markerSize / 2,
width: markerSize,
height: markerSize
))
// White border
context.setStrokeColor(UIColor.white.cgColor)
context.setLineWidth(2)
context.strokeEllipse(in: CGRect(
x: point.x - markerSize / 2,
y: point.y - markerSize / 2,
width: markerSize,
height: markerSize
))
}
}
// MARK: - Card Generator Errors
enum CardGeneratorError: Error, LocalizedError {
case renderingFailed
case mapSnapshotFailed
var errorDescription: String? {
switch self {
case .renderingFailed:
return "Failed to render progress card"
case .mapSnapshotFailed:
return "Failed to generate map snapshot"
}
}
}
// MARK: - Progress Card View
struct ProgressCardView: View {
let progress: LeagueProgress
let options: ProgressCardOptions
let mapSnapshot: UIImage?
var body: some View {
ZStack {
// Background gradient
LinearGradient(
colors: options.cardStyle == .dark
? [Color(hex: "1A1A2E"), Color(hex: "16213E")]
: [Color.white, Color(hex: "F5F5F5")],
startPoint: .top,
endPoint: .bottom
)
VStack(spacing: 40) {
// App logo and title
headerSection
Spacer()
// Progress ring
progressRingSection
// Stats row
if options.includeStats {
statsSection
}
// Map snapshot
if options.includeMapSnapshot, let snapshot = mapSnapshot {
mapSection(image: snapshot)
}
Spacer()
// Username if included
if options.includeUsername, let username = options.username, !username.isEmpty {
usernameSection(username)
}
// App branding footer
footerSection
}
.padding(60)
}
.frame(width: 1080, height: 1920)
}
// MARK: - Header
private var headerSection: some View {
VStack(spacing: 16) {
// Sport icon
ZStack {
Circle()
.fill(progress.sport.themeColor.opacity(0.2))
.frame(width: 80, height: 80)
Image(systemName: progress.sport.iconName)
.font(.system(size: 40))
.foregroundStyle(progress.sport.themeColor)
}
Text("\(progress.sport.displayName) Stadium Quest")
.font(.system(size: 48, weight: .bold, design: .rounded))
.foregroundStyle(options.cardStyle.textColor)
}
}
// MARK: - Progress Ring
private var progressRingSection: some View {
ZStack {
// Background ring
Circle()
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 24)
.frame(width: 320, height: 320)
// Progress ring
Circle()
.trim(from: 0, to: progress.completionPercentage / 100)
.stroke(
Theme.warmOrange,
style: StrokeStyle(lineWidth: 24, lineCap: .round)
)
.frame(width: 320, height: 320)
.rotationEffect(.degrees(-90))
// Center content
VStack(spacing: 8) {
Text("\(progress.visitedStadiums)")
.font(.system(size: 96, weight: .bold, design: .rounded))
.foregroundStyle(options.cardStyle.textColor)
Text("of \(progress.totalStadiums)")
.font(.system(size: 32, weight: .medium))
.foregroundStyle(options.cardStyle.secondaryTextColor)
Text("Stadiums Visited")
.font(.system(size: 24))
.foregroundStyle(options.cardStyle.secondaryTextColor)
}
}
}
// MARK: - Stats
private var statsSection: some View {
HStack(spacing: 60) {
statItem(value: "\(progress.visitedStadiums)", label: "Visited")
statItem(value: "\(progress.totalStadiums - progress.visitedStadiums)", label: "Remaining")
statItem(value: String(format: "%.0f%%", progress.completionPercentage), label: "Complete")
}
.padding(.vertical, 30)
.padding(.horizontal, 40)
.background(
RoundedRectangle(cornerRadius: 20)
.fill(options.cardStyle == .dark
? Color.white.opacity(0.05)
: Color.black.opacity(0.05))
)
}
private func statItem(value: String, label: String) -> some View {
VStack(spacing: 8) {
Text(value)
.font(.system(size: 36, weight: .bold, design: .rounded))
.foregroundStyle(Theme.warmOrange)
Text(label)
.font(.system(size: 20))
.foregroundStyle(options.cardStyle.secondaryTextColor)
}
}
// MARK: - Map
private func mapSection(image: UIImage) -> some View {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
.frame(maxWidth: 960)
.clipShape(RoundedRectangle(cornerRadius: 20))
.overlay {
RoundedRectangle(cornerRadius: 20)
.stroke(Theme.warmOrange.opacity(0.3), lineWidth: 2)
}
}
// MARK: - Username
private func usernameSection(_ username: String) -> some View {
HStack(spacing: 12) {
Image(systemName: "person.circle.fill")
.font(.system(size: 24))
Text(username)
.font(.system(size: 28, weight: .medium))
}
.foregroundStyle(options.cardStyle.secondaryTextColor)
}
// MARK: - Footer
private var footerSection: some View {
VStack(spacing: 12) {
HStack(spacing: 8) {
Image(systemName: "sportscourt.fill")
.font(.system(size: 20))
Text("SportsTime")
.font(.system(size: 24, weight: .semibold))
}
.foregroundStyle(Theme.warmOrange)
Text("Track your stadium adventures")
.font(.system(size: 18))
.foregroundStyle(options.cardStyle.secondaryTextColor)
}
}
}
// MARK: - Progress Share View
struct ProgressShareView: View {
let progress: LeagueProgress
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dismiss) private var dismiss
@State private var generatedImage: UIImage?
@State private var isGenerating = false
@State private var showShareSheet = false
@State private var error: String?
@State private var includeUsername = true
@State private var username = ""
@State private var includeMap = true
@State private var cardStyle: ProgressCardOptions.CardStyle = .dark
var body: some View {
NavigationStack {
ScrollView {
VStack(spacing: Theme.Spacing.lg) {
// Preview card
previewCard
.padding(.horizontal)
// Options
optionsSection
// Generate button
generateButton
.padding(.horizontal)
}
.padding(.vertical)
}
.navigationTitle("Share Progress")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
}
.sheet(isPresented: $showShareSheet) {
if let image = generatedImage {
ShareSheet(items: [image])
}
}
.alert("Error", isPresented: .constant(error != nil)) {
Button("OK") { error = nil }
} message: {
Text(error ?? "")
}
}
}
private var previewCard: some View {
VStack(spacing: Theme.Spacing.md) {
Text("Preview")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textMuted(colorScheme))
// Mini preview
ZStack {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.fill(cardStyle == .dark
? Color(hex: "1A1A2E")
: Color.white)
.aspectRatio(9/16, contentMode: .fit)
.frame(maxHeight: 300)
VStack(spacing: 12) {
// Sport badge
HStack(spacing: 4) {
Image(systemName: progress.sport.iconName)
Text(progress.sport.displayName)
}
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(progress.sport.themeColor)
// Progress ring
ZStack {
Circle()
.stroke(Theme.warmOrange.opacity(0.2), lineWidth: 4)
.frame(width: 60, height: 60)
Circle()
.trim(from: 0, to: progress.completionPercentage / 100)
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 4, lineCap: .round))
.frame(width: 60, height: 60)
.rotationEffect(.degrees(-90))
VStack(spacing: 0) {
Text("\(progress.visitedStadiums)")
.font(.system(size: 18, weight: .bold))
Text("/\(progress.totalStadiums)")
.font(.system(size: 10))
}
.foregroundStyle(cardStyle == .dark ? .white : .black)
}
if includeMap {
RoundedRectangle(cornerRadius: 4)
.fill(Color.gray.opacity(0.2))
.frame(height: 40)
.overlay {
Image(systemName: "map")
.foregroundStyle(Color.gray)
}
}
if includeUsername && !username.isEmpty {
Text("@\(username)")
.font(.system(size: 10))
.foregroundStyle(cardStyle == .dark ? Color.gray : Color.gray)
}
// Branding
HStack(spacing: 4) {
Image(systemName: "sportscourt.fill")
Text("SportsTime")
}
.font(.system(size: 10, weight: .medium))
.foregroundStyle(Theme.warmOrange)
}
.padding()
}
}
}
private var optionsSection: some View {
VStack(spacing: Theme.Spacing.md) {
// Style selector
VStack(alignment: .leading, spacing: Theme.Spacing.xs) {
Text("Style")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textMuted(colorScheme))
HStack(spacing: Theme.Spacing.sm) {
styleButton(style: .dark, label: "Dark")
styleButton(style: .light, label: "Light")
}
}
.padding(.horizontal)
// Username toggle
Toggle(isOn: $includeUsername) {
Text("Include Username")
.font(.system(size: Theme.FontSize.body))
}
.padding(.horizontal)
if includeUsername {
TextField("Username", text: $username)
.textFieldStyle(.roundedBorder)
.padding(.horizontal)
}
// Map toggle
Toggle(isOn: $includeMap) {
Text("Include Map")
.font(.system(size: Theme.FontSize.body))
}
.padding(.horizontal)
}
.padding(.vertical)
.background(Theme.cardBackground(colorScheme))
}
private func styleButton(style: ProgressCardOptions.CardStyle, label: String) -> some View {
Button {
withAnimation { cardStyle = style }
} label: {
Text(label)
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(cardStyle == style ? .white : Theme.textPrimary(colorScheme))
.padding(.horizontal, Theme.Spacing.md)
.padding(.vertical, Theme.Spacing.sm)
.background(cardStyle == style ? Theme.warmOrange : Theme.cardBackgroundElevated(colorScheme))
.clipShape(Capsule())
}
.buttonStyle(.plain)
}
private var generateButton: some View {
Button {
generateCard()
} label: {
HStack {
if isGenerating {
ThemedSpinnerCompact(size: 18, color: .white)
} else {
Image(systemName: "square.and.arrow.up")
}
Text(isGenerating ? "Generating..." : "Generate & Share")
}
.frame(maxWidth: .infinity)
.padding(Theme.Spacing.md)
.background(Theme.warmOrange)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
}
.disabled(isGenerating)
}
private func generateCard() {
isGenerating = true
Task {
let options = ProgressCardOptions(
includeUsername: includeUsername,
username: username,
includeMapSnapshot: includeMap,
includeStats: true,
cardStyle: cardStyle
)
let generator = ProgressCardGenerator()
do {
generatedImage = try await generator.generateCard(
progress: progress,
options: options
)
showShareSheet = true
} catch {
self.error = error.localizedDescription
}
isGenerating = false
}
}
}
// MARK: - Preview
#Preview {
ProgressShareView(progress: LeagueProgress(
sport: .mlb,
totalStadiums: 30,
visitedStadiums: 12,
stadiumsVisited: [],
stadiumsRemaining: []
))
}

View File

@@ -16,6 +16,7 @@ struct HomeView: View {
@State private var selectedTab = 0
@State private var suggestedTripsGenerator = SuggestedTripsGenerator()
@State private var selectedSuggestedTrip: SuggestedTrip?
@State private var tripCreationViewModel = TripCreationViewModel()
var body: some View {
TabView(selection: $selectedTab) {
@@ -83,6 +84,15 @@ struct HomeView: View {
}
.tag(2)
// Progress Tab
NavigationStack {
ProgressTabView()
}
.tabItem {
Label("Progress", systemImage: "chart.bar.fill")
}
.tag(3)
// Settings Tab
NavigationStack {
SettingsView()
@@ -90,11 +100,11 @@ struct HomeView: View {
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag(3)
.tag(4)
}
.tint(Theme.warmOrange)
.sheet(isPresented: $showNewTrip) {
TripCreationView(initialSport: selectedSport)
TripCreationView(viewModel: tripCreationViewModel, initialSport: selectedSport)
}
.onChange(of: showNewTrip) { _, isShowing in
if !isShowing {
@@ -110,6 +120,7 @@ struct HomeView: View {
NavigationStack {
TripDetailView(trip: suggestedTrip.trip, games: suggestedTrip.richGames)
}
.interactiveDismissDisabled()
}
}

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")
}
}

View File

@@ -6,6 +6,7 @@
import SwiftUI
struct ScheduleListView: View {
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel = ScheduleViewModel()
@State private var showDatePicker = false
@@ -97,9 +98,11 @@ struct ScheduleListView: View {
Text(formatSectionDate(dateGroup.date))
.font(.headline)
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.refreshable {
await viewModel.loadGames()
}
@@ -128,8 +131,7 @@ struct ScheduleListView: View {
private var loadingView: some View {
VStack(spacing: 16) {
ProgressView()
.scaleEffect(1.5)
ThemedSpinner(size: 44)
Text("Loading schedule...")
.foregroundStyle(.secondary)
}

View File

@@ -6,6 +6,7 @@
import SwiftUI
struct SettingsView: View {
@Environment(\.colorScheme) private var colorScheme
@State private var viewModel = SettingsViewModel()
@State private var showResetConfirmation = false
@@ -91,6 +92,7 @@ struct SettingsView: View {
} footer: {
Text("Choose a color scheme for the app.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Sports Section
@@ -115,6 +117,7 @@ struct SettingsView: View {
} footer: {
Text("Selected sports will be shown by default in schedules and trip planning.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Travel Section
@@ -159,6 +162,7 @@ struct SettingsView: View {
} footer: {
Text("Trips will be optimized to keep daily driving within this limit.")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Data Section
@@ -176,7 +180,7 @@ struct SettingsView: View {
Spacer()
if viewModel.isSyncing {
ProgressView()
ThemedSpinnerCompact(size: 18)
}
}
}
@@ -209,6 +213,7 @@ struct SettingsView: View {
Text("Schedule data is synced from CloudKit.")
#endif
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - About Section
@@ -236,6 +241,7 @@ struct SettingsView: View {
} header: {
Text("About")
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Reset Section
@@ -248,6 +254,7 @@ struct SettingsView: View {
Label("Reset to Defaults", systemImage: "arrow.counterclockwise")
}
}
.listRowBackground(Theme.cardBackground(colorScheme))
}
// MARK: - Helpers

View File

@@ -348,6 +348,10 @@ final class TripCreationViewModel {
}
}
func deselectAllGames() {
mustSeeGameIds.removeAll()
}
func switchPlanningMode(_ mode: PlanningMode) {
planningMode = mode
// Clear mode-specific selections when switching

View File

@@ -9,11 +9,11 @@ struct TripCreationView: View {
@Environment(\.dismiss) private var dismiss
@Environment(\.colorScheme) private var colorScheme
@Bindable var viewModel: TripCreationViewModel
let initialSport: Sport?
@State private var viewModel = TripCreationViewModel()
init(initialSport: Sport? = nil) {
init(viewModel: TripCreationViewModel, initialSport: Sport? = nil) {
self.viewModel = viewModel
self.initialSport = initialSport
}
@State private var showGamePicker = false
@@ -25,6 +25,16 @@ struct TripCreationView: View {
@State private var completedTrip: Trip?
@State private var tripOptions: [ItineraryOption] = []
// Location search state
@State private var startLocationSuggestions: [LocationSearchResult] = []
@State private var endLocationSuggestions: [LocationSearchResult] = []
@State private var startSearchTask: Task<Void, Never>?
@State private var endSearchTask: Task<Void, Never>?
@State private var isSearchingStart = false
@State private var isSearchingEnd = false
private let locationService = LocationService.shared
enum CityInputType {
case mustStop
case preferred
@@ -214,35 +224,192 @@ struct TripCreationView: View {
private var locationSection: some View {
ThemedSection(title: "Locations") {
ThemedTextField(
label: "Start Location",
placeholder: "Where are you starting from?",
text: $viewModel.startLocationText,
icon: "location.circle.fill"
)
// Start Location with suggestions
VStack(alignment: .leading, spacing: 0) {
ThemedTextField(
label: "Start Location",
placeholder: "Where are you starting from?",
text: $viewModel.startLocationText,
icon: "location.circle.fill"
)
.onChange(of: viewModel.startLocationText) { _, newValue in
searchLocation(query: newValue, isStart: true)
}
ThemedTextField(
label: "End Location",
placeholder: "Where do you want to end up?",
text: $viewModel.endLocationText,
icon: "mappin.circle.fill"
)
// Suggestions for start location
if !startLocationSuggestions.isEmpty {
locationSuggestionsList(
suggestions: startLocationSuggestions,
isLoading: isSearchingStart
) { result in
viewModel.startLocationText = result.name
viewModel.startLocation = result.toLocationInput()
startLocationSuggestions = []
}
} else if isSearchingStart {
HStack {
ThemedSpinnerCompact(size: 14)
Text("Searching...")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(.top, Theme.Spacing.xs)
}
}
// End Location with suggestions
VStack(alignment: .leading, spacing: 0) {
ThemedTextField(
label: "End Location",
placeholder: "Where do you want to end up?",
text: $viewModel.endLocationText,
icon: "mappin.circle.fill"
)
.onChange(of: viewModel.endLocationText) { _, newValue in
searchLocation(query: newValue, isStart: false)
}
// Suggestions for end location
if !endLocationSuggestions.isEmpty {
locationSuggestionsList(
suggestions: endLocationSuggestions,
isLoading: isSearchingEnd
) { result in
viewModel.endLocationText = result.name
viewModel.endLocation = result.toLocationInput()
endLocationSuggestions = []
}
} else if isSearchingEnd {
HStack {
ThemedSpinnerCompact(size: 14)
Text("Searching...")
.font(.system(size: Theme.FontSize.caption))
.foregroundStyle(Theme.textMuted(colorScheme))
}
.padding(.top, Theme.Spacing.xs)
}
}
}
}
private func searchLocation(query: String, isStart: Bool) {
// Cancel previous search
if isStart {
startSearchTask?.cancel()
} else {
endSearchTask?.cancel()
}
guard query.count >= 2 else {
if isStart {
startLocationSuggestions = []
isSearchingStart = false
} else {
endLocationSuggestions = []
isSearchingEnd = false
}
return
}
let task = Task {
// Debounce
try? await Task.sleep(for: .milliseconds(300))
guard !Task.isCancelled else { return }
if isStart {
isSearchingStart = true
} else {
isSearchingEnd = true
}
do {
let results = try await locationService.searchLocations(query)
guard !Task.isCancelled else { return }
if isStart {
startLocationSuggestions = Array(results.prefix(5))
isSearchingStart = false
} else {
endLocationSuggestions = Array(results.prefix(5))
isSearchingEnd = false
}
} catch {
if isStart {
startLocationSuggestions = []
isSearchingStart = false
} else {
endLocationSuggestions = []
isSearchingEnd = false
}
}
}
if isStart {
startSearchTask = task
} else {
endSearchTask = task
}
}
@ViewBuilder
private func locationSuggestionsList(
suggestions: [LocationSearchResult],
isLoading: Bool,
onSelect: @escaping (LocationSearchResult) -> Void
) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(suggestions) { result in
Button {
onSelect(result)
} label: {
HStack(spacing: Theme.Spacing.sm) {
Image(systemName: "mappin.circle.fill")
.foregroundStyle(Theme.warmOrange)
.font(.system(size: 14))
VStack(alignment: .leading, spacing: 2) {
Text(result.name)
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
if !result.address.isEmpty {
Text(result.address)
.font(.system(size: Theme.FontSize.micro))
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
Spacer()
}
.padding(.vertical, Theme.Spacing.sm)
.padding(.horizontal, Theme.Spacing.xs)
}
.buttonStyle(.plain)
if result.id != suggestions.last?.id {
Divider()
.overlay(Theme.surfaceGlow(colorScheme))
}
}
}
.padding(.top, Theme.Spacing.xs)
.background(Theme.cardBackgroundElevated(colorScheme))
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.small))
}
private var gameBrowserSection: some View {
ThemedSection(title: "Select Games") {
if viewModel.isLoadingGames || viewModel.availableGames.isEmpty {
HStack(spacing: Theme.Spacing.sm) {
ProgressView()
.tint(Theme.warmOrange)
ThemedSpinnerCompact(size: 20)
Text("Loading games...")
.font(.system(size: Theme.FontSize.body))
.foregroundStyle(Theme.textSecondary(colorScheme))
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, Theme.Spacing.md)
.task {
.task(id: viewModel.selectedSports) {
// Re-run when sports selection changes
if viewModel.availableGames.isEmpty {
await viewModel.loadGamesForBrowsing()
}
@@ -290,6 +457,16 @@ struct TripCreationView: View {
Text("\(viewModel.mustSeeGameIds.count) game(s) selected")
.font(.system(size: Theme.FontSize.body, weight: .medium))
.foregroundStyle(Theme.textPrimary(colorScheme))
Spacer()
Button {
viewModel.deselectAllGames()
} label: {
Text("Deselect All")
.font(.system(size: Theme.FontSize.caption, weight: .medium))
.foregroundStyle(.red)
}
}
// Show selected games preview
@@ -927,8 +1104,7 @@ struct LocationSearchSheet: View {
.textFieldStyle(.plain)
.autocorrectionDisabled()
if isSearching {
ProgressView()
.scaleEffect(0.8)
ThemedSpinnerCompact(size: 16)
} else if !searchText.isEmpty {
Button {
searchText = ""
@@ -1260,8 +1436,7 @@ struct TripOptionCard: View {
.transition(.opacity)
} else if isLoadingDescription {
HStack(spacing: 4) {
ProgressView()
.scaleEffect(0.6)
ThemedSpinnerCompact(size: 12)
Text("Generating...")
.font(.system(size: 11))
.foregroundStyle(Theme.textMuted(colorScheme))
@@ -1806,5 +1981,5 @@ struct SportSelectionChip: View {
}
#Preview {
TripCreationView()
TripCreationView(viewModel: TripCreationViewModel())
}

View File

@@ -198,8 +198,7 @@ struct TripDetailView: View {
// Loading indicator
if isLoadingRoutes {
ProgressView()
.tint(Theme.warmOrange)
ThemedSpinnerCompact(size: 24)
.padding(.bottom, 40)
}
}

View File

@@ -0,0 +1,227 @@
[
{
"id": "mlb_league",
"sport": "MLB",
"type": "league",
"name": "Major League Baseball",
"abbreviation": "MLB",
"parent_id": null,
"display_order": 0
},
{
"id": "mlb_al",
"sport": "MLB",
"type": "conference",
"name": "American League",
"abbreviation": "AL",
"parent_id": "mlb_league",
"display_order": 1
},
{
"id": "mlb_nl",
"sport": "MLB",
"type": "conference",
"name": "National League",
"abbreviation": "NL",
"parent_id": "mlb_league",
"display_order": 2
},
{
"id": "mlb_al_east",
"sport": "MLB",
"type": "division",
"name": "AL East",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 1
},
{
"id": "mlb_al_central",
"sport": "MLB",
"type": "division",
"name": "AL Central",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 2
},
{
"id": "mlb_al_west",
"sport": "MLB",
"type": "division",
"name": "AL West",
"abbreviation": null,
"parent_id": "mlb_al",
"display_order": 3
},
{
"id": "mlb_nl_east",
"sport": "MLB",
"type": "division",
"name": "NL East",
"abbreviation": null,
"parent_id": "mlb_nl",
"display_order": 1
},
{
"id": "mlb_nl_central",
"sport": "MLB",
"type": "division",
"name": "NL Central",
"abbreviation": null,
"parent_id": "mlb_nl",
"display_order": 2
},
{
"id": "mlb_nl_west",
"sport": "MLB",
"type": "division",
"name": "NL West",
"abbreviation": null,
"parent_id": "mlb_nl",
"display_order": 3
},
{
"id": "nba_league",
"sport": "NBA",
"type": "league",
"name": "National Basketball Association",
"abbreviation": "NBA",
"parent_id": null,
"display_order": 0
},
{
"id": "nba_eastern",
"sport": "NBA",
"type": "conference",
"name": "Eastern Conference",
"abbreviation": "East",
"parent_id": "nba_league",
"display_order": 1
},
{
"id": "nba_western",
"sport": "NBA",
"type": "conference",
"name": "Western Conference",
"abbreviation": "West",
"parent_id": "nba_league",
"display_order": 2
},
{
"id": "nba_atlantic",
"sport": "NBA",
"type": "division",
"name": "Atlantic",
"abbreviation": null,
"parent_id": "nba_eastern",
"display_order": 1
},
{
"id": "nba_central",
"sport": "NBA",
"type": "division",
"name": "Central",
"abbreviation": null,
"parent_id": "nba_eastern",
"display_order": 2
},
{
"id": "nba_southeast",
"sport": "NBA",
"type": "division",
"name": "Southeast",
"abbreviation": null,
"parent_id": "nba_eastern",
"display_order": 3
},
{
"id": "nba_northwest",
"sport": "NBA",
"type": "division",
"name": "Northwest",
"abbreviation": null,
"parent_id": "nba_western",
"display_order": 1
},
{
"id": "nba_pacific",
"sport": "NBA",
"type": "division",
"name": "Pacific",
"abbreviation": null,
"parent_id": "nba_western",
"display_order": 2
},
{
"id": "nba_southwest",
"sport": "NBA",
"type": "division",
"name": "Southwest",
"abbreviation": null,
"parent_id": "nba_western",
"display_order": 3
},
{
"id": "nhl_league",
"sport": "NHL",
"type": "league",
"name": "National Hockey League",
"abbreviation": "NHL",
"parent_id": null,
"display_order": 0
},
{
"id": "nhl_eastern",
"sport": "NHL",
"type": "conference",
"name": "Eastern Conference",
"abbreviation": "East",
"parent_id": "nhl_league",
"display_order": 1
},
{
"id": "nhl_western",
"sport": "NHL",
"type": "conference",
"name": "Western Conference",
"abbreviation": "West",
"parent_id": "nhl_league",
"display_order": 2
},
{
"id": "nhl_atlantic",
"sport": "NHL",
"type": "division",
"name": "Atlantic",
"abbreviation": null,
"parent_id": "nhl_eastern",
"display_order": 1
},
{
"id": "nhl_metropolitan",
"sport": "NHL",
"type": "division",
"name": "Metropolitan",
"abbreviation": null,
"parent_id": "nhl_eastern",
"display_order": 2
},
{
"id": "nhl_central",
"sport": "NHL",
"type": "division",
"name": "Central",
"abbreviation": null,
"parent_id": "nhl_western",
"display_order": 1
},
{
"id": "nhl_pacific",
"sport": "NHL",
"type": "division",
"name": "Pacific",
"abbreviation": null,
"parent_id": "nhl_western",
"display_order": 2
}
]

View File

@@ -0,0 +1,306 @@
[
{
"id": "alias_nba_brk_njn",
"team_canonical_id": "team_nba_brk",
"alias_type": "abbreviation",
"alias_value": "NJN",
"valid_from": null,
"valid_until": "2012-05-01T00:00:00Z"
},
{
"id": "alias_nba_brk_nj_nets",
"team_canonical_id": "team_nba_brk",
"alias_type": "name",
"alias_value": "New Jersey Nets",
"valid_from": null,
"valid_until": "2012-05-01T00:00:00Z"
},
{
"id": "alias_nba_brk_nj_city",
"team_canonical_id": "team_nba_brk",
"alias_type": "city",
"alias_value": "New Jersey",
"valid_from": null,
"valid_until": "2012-05-01T00:00:00Z"
},
{
"id": "alias_nba_okc_sea",
"team_canonical_id": "team_nba_okc",
"alias_type": "abbreviation",
"alias_value": "SEA",
"valid_from": null,
"valid_until": "2008-07-01T00:00:00Z"
},
{
"id": "alias_nba_okc_sonics",
"team_canonical_id": "team_nba_okc",
"alias_type": "name",
"alias_value": "Seattle SuperSonics",
"valid_from": null,
"valid_until": "2008-07-01T00:00:00Z"
},
{
"id": "alias_nba_okc_seattle",
"team_canonical_id": "team_nba_okc",
"alias_type": "city",
"alias_value": "Seattle",
"valid_from": null,
"valid_until": "2008-07-01T00:00:00Z"
},
{
"id": "alias_nba_mem_van",
"team_canonical_id": "team_nba_mem",
"alias_type": "abbreviation",
"alias_value": "VAN",
"valid_from": null,
"valid_until": "2001-05-01T00:00:00Z"
},
{
"id": "alias_nba_mem_vancouver",
"team_canonical_id": "team_nba_mem",
"alias_type": "name",
"alias_value": "Vancouver Grizzlies",
"valid_from": null,
"valid_until": "2001-05-01T00:00:00Z"
},
{
"id": "alias_nba_nop_noh",
"team_canonical_id": "team_nba_nop",
"alias_type": "abbreviation",
"alias_value": "NOH",
"valid_from": "2002-01-01T00:00:00Z",
"valid_until": "2013-05-01T00:00:00Z"
},
{
"id": "alias_nba_nop_hornets",
"team_canonical_id": "team_nba_nop",
"alias_type": "name",
"alias_value": "New Orleans Hornets",
"valid_from": "2002-01-01T00:00:00Z",
"valid_until": "2013-05-01T00:00:00Z"
},
{
"id": "alias_nba_cho_cha_old",
"team_canonical_id": "team_nba_cho",
"alias_type": "abbreviation",
"alias_value": "CHA",
"valid_from": "2014-01-01T00:00:00Z",
"valid_until": null
},
{
"id": "alias_nba_was_wsh",
"team_canonical_id": "team_nba_was",
"alias_type": "abbreviation",
"alias_value": "WSH",
"valid_from": null,
"valid_until": null
},
{
"id": "alias_nba_uta_utj",
"team_canonical_id": "team_nba_uta",
"alias_type": "abbreviation",
"alias_value": "UTJ",
"valid_from": null,
"valid_until": null
},
{
"id": "alias_nba_pho_phx",
"team_canonical_id": "team_nba_pho",
"alias_type": "abbreviation",
"alias_value": "PHX",
"valid_from": null,
"valid_until": null
},
{
"id": "alias_mlb_mia_fla",
"team_canonical_id": "team_mlb_mia",
"alias_type": "abbreviation",
"alias_value": "FLA",
"valid_from": null,
"valid_until": "2012-01-01T00:00:00Z"
},
{
"id": "alias_mlb_mia_marlins_fl",
"team_canonical_id": "team_mlb_mia",
"alias_type": "name",
"alias_value": "Florida Marlins",
"valid_from": null,
"valid_until": "2012-01-01T00:00:00Z"
},
{
"id": "alias_mlb_mia_florida",
"team_canonical_id": "team_mlb_mia",
"alias_type": "city",
"alias_value": "Florida",
"valid_from": null,
"valid_until": "2012-01-01T00:00:00Z"
},
{
"id": "alias_mlb_laa_ana",
"team_canonical_id": "team_mlb_laa",
"alias_type": "abbreviation",
"alias_value": "ANA",
"valid_from": null,
"valid_until": "2005-01-01T00:00:00Z"
},
{
"id": "alias_mlb_laa_angels_ana",
"team_canonical_id": "team_mlb_laa",
"alias_type": "name",
"alias_value": "Anaheim Angels",
"valid_from": null,
"valid_until": "2005-01-01T00:00:00Z"
},
{
"id": "alias_mlb_laa_california",
"team_canonical_id": "team_mlb_laa",
"alias_type": "name",
"alias_value": "California Angels",
"valid_from": null,
"valid_until": "1996-01-01T00:00:00Z"
},
{
"id": "alias_mlb_tbr_tbd",
"team_canonical_id": "team_mlb_tbr",
"alias_type": "abbreviation",
"alias_value": "TBD",
"valid_from": null,
"valid_until": "2008-01-01T00:00:00Z"
},
{
"id": "alias_mlb_tbr_devil_rays",
"team_canonical_id": "team_mlb_tbr",
"alias_type": "name",
"alias_value": "Tampa Bay Devil Rays",
"valid_from": null,
"valid_until": "2008-01-01T00:00:00Z"
},
{
"id": "alias_mlb_was_mon",
"team_canonical_id": "team_mlb_was",
"alias_type": "abbreviation",
"alias_value": "MON",
"valid_from": null,
"valid_until": "2005-01-01T00:00:00Z"
},
{
"id": "alias_mlb_was_expos",
"team_canonical_id": "team_mlb_was",
"alias_type": "name",
"alias_value": "Montreal Expos",
"valid_from": null,
"valid_until": "2005-01-01T00:00:00Z"
},
{
"id": "alias_mlb_was_montreal",
"team_canonical_id": "team_mlb_was",
"alias_type": "city",
"alias_value": "Montreal",
"valid_from": null,
"valid_until": "2005-01-01T00:00:00Z"
},
{
"id": "alias_nhl_ari_pho",
"team_canonical_id": "team_nhl_uta",
"alias_type": "abbreviation",
"alias_value": "ARI",
"valid_from": "1996-01-01T00:00:00Z",
"valid_until": "2024-05-01T00:00:00Z"
},
{
"id": "alias_nhl_ari_coyotes",
"team_canonical_id": "team_nhl_uta",
"alias_type": "name",
"alias_value": "Arizona Coyotes",
"valid_from": "1996-01-01T00:00:00Z",
"valid_until": "2024-05-01T00:00:00Z"
},
{
"id": "alias_nhl_ari_phoenix",
"team_canonical_id": "team_nhl_uta",
"alias_type": "name",
"alias_value": "Phoenix Coyotes",
"valid_from": "1996-01-01T00:00:00Z",
"valid_until": "2014-01-01T00:00:00Z"
},
{
"id": "alias_nhl_wpg_atl",
"team_canonical_id": "team_nhl_wpg",
"alias_type": "abbreviation",
"alias_value": "ATL",
"valid_from": "1999-01-01T00:00:00Z",
"valid_until": "2011-05-01T00:00:00Z"
},
{
"id": "alias_nhl_wpg_thrashers",
"team_canonical_id": "team_nhl_wpg",
"alias_type": "name",
"alias_value": "Atlanta Thrashers",
"valid_from": "1999-01-01T00:00:00Z",
"valid_until": "2011-05-01T00:00:00Z"
},
{
"id": "alias_nhl_car_htf",
"team_canonical_id": "team_nhl_car",
"alias_type": "abbreviation",
"alias_value": "HTF",
"valid_from": null,
"valid_until": "1997-01-01T00:00:00Z"
},
{
"id": "alias_nhl_car_whalers",
"team_canonical_id": "team_nhl_car",
"alias_type": "name",
"alias_value": "Hartford Whalers",
"valid_from": null,
"valid_until": "1997-01-01T00:00:00Z"
},
{
"id": "alias_nhl_col_que",
"team_canonical_id": "team_nhl_col",
"alias_type": "abbreviation",
"alias_value": "QUE",
"valid_from": null,
"valid_until": "1995-05-01T00:00:00Z"
},
{
"id": "alias_nhl_col_nordiques",
"team_canonical_id": "team_nhl_col",
"alias_type": "name",
"alias_value": "Quebec Nordiques",
"valid_from": null,
"valid_until": "1995-05-01T00:00:00Z"
},
{
"id": "alias_nhl_dal_mns",
"team_canonical_id": "team_nhl_dal",
"alias_type": "abbreviation",
"alias_value": "MNS",
"valid_from": null,
"valid_until": "1993-05-01T00:00:00Z"
},
{
"id": "alias_nhl_dal_north_stars",
"team_canonical_id": "team_nhl_dal",
"alias_type": "name",
"alias_value": "Minnesota North Stars",
"valid_from": null,
"valid_until": "1993-05-01T00:00:00Z"
},
{
"id": "alias_nhl_ana_mda",
"team_canonical_id": "team_nhl_ana",
"alias_type": "name",
"alias_value": "Mighty Ducks of Anaheim",
"valid_from": null,
"valid_until": "2006-06-01T00:00:00Z"
},
{
"id": "alias_nhl_vgk_lv",
"team_canonical_id": "team_nhl_vgk",
"alias_type": "abbreviation",
"alias_value": "LV",
"valid_from": null,
"valid_until": null
}
]

View File

@@ -12,10 +12,24 @@ import SwiftData
struct SportsTimeApp: App {
var sharedModelContainer: ModelContainer = {
let schema = Schema([
// User data models
SavedTrip.self,
TripVote.self,
UserPreferences.self,
CachedSchedule.self,
// Stadium progress models
StadiumVisit.self,
VisitPhotoMetadata.self,
Achievement.self,
CachedGameScore.self,
// Canonical data models
SyncState.self,
CanonicalStadium.self,
StadiumAlias.self,
CanonicalTeam.self,
TeamAlias.self,
LeagueStructureModel.self,
CanonicalGame.self,
])
let modelConfiguration = ModelConfiguration(
schema: schema,
@@ -32,8 +46,100 @@ struct SportsTimeApp: App {
var body: some Scene {
WindowGroup {
HomeView()
BootstrappedContentView(modelContainer: sharedModelContainer)
}
.modelContainer(sharedModelContainer)
}
}
// MARK: - Bootstrapped Content View
/// Wraps the main content with bootstrap logic.
/// Shows a loading indicator until bootstrap completes, then shows HomeView.
struct BootstrappedContentView: View {
let modelContainer: ModelContainer
@State private var isBootstrapping = true
@State private var bootstrapError: Error?
var body: some View {
Group {
if isBootstrapping {
BootstrapLoadingView()
} else if let error = bootstrapError {
BootstrapErrorView(error: error) {
Task {
await performBootstrap()
}
}
} else {
HomeView()
}
}
.task {
await performBootstrap()
}
}
@MainActor
private func performBootstrap() async {
isBootstrapping = true
bootstrapError = nil
let context = modelContainer.mainContext
let bootstrapService = BootstrapService()
do {
try await bootstrapService.bootstrapIfNeeded(context: context)
isBootstrapping = false
} catch {
bootstrapError = error
isBootstrapping = false
}
}
}
// MARK: - Bootstrap Loading View
struct BootstrapLoadingView: View {
var body: some View {
VStack(spacing: 20) {
ThemedSpinner(size: 50, lineWidth: 4)
Text("Setting up SportsTime...")
.font(.headline)
.foregroundStyle(.secondary)
}
}
}
// MARK: - Bootstrap Error View
struct BootstrapErrorView: View {
let error: Error
let onRetry: () -> Void
var body: some View {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 50))
.foregroundStyle(.orange)
Text("Setup Failed")
.font(.title2)
.fontWeight(.semibold)
Text(error.localizedDescription)
.font(.subheadline)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
Button("Try Again") {
onRetry()
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}