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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
647
SportsTime/Core/Models/Domain/AchievementDefinitions.swift
Normal file
647
SportsTime/Core/Models/Domain/AchievementDefinitions.swift
Normal 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 }
|
||||
}
|
||||
}
|
||||
119
SportsTime/Core/Models/Domain/Division.swift
Normal file
119
SportsTime/Core/Models/Domain/Division.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
232
SportsTime/Core/Models/Domain/Progress.swift
Normal file
232
SportsTime/Core/Models/Domain/Progress.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
492
SportsTime/Core/Models/Local/CanonicalModels.swift
Normal file
492
SportsTime/Core/Models/Local/CanonicalModels.swift
Normal 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()
|
||||
}
|
||||
364
SportsTime/Core/Models/Local/StadiumProgress.swift
Normal file
364
SportsTime/Core/Models/Local/StadiumProgress.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user