Initial commit: SportsTime trip planning app
- Three-scenario planning engine (A: date range, B: selected games, C: directional routes) - GeographicRouteExplorer with anchor game support for route exploration - Shared ItineraryBuilder for travel segment calculation - TravelEstimator for driving time/distance estimation - SwiftUI views for trip creation and detail display - CloudKit integration for schedule data - Python scraping scripts for sports schedules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
193
SportsTime/Core/Models/CloudKit/CKModels.swift
Normal file
193
SportsTime/Core/Models/CloudKit/CKModels.swift
Normal file
@@ -0,0 +1,193 @@
|
||||
//
|
||||
// CKModels.swift
|
||||
// SportsTime
|
||||
//
|
||||
// CloudKit Record Type Definitions for Public Database
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
// MARK: - Record Type Constants
|
||||
|
||||
enum CKRecordType {
|
||||
static let team = "Team"
|
||||
static let stadium = "Stadium"
|
||||
static let game = "Game"
|
||||
static let sport = "Sport"
|
||||
}
|
||||
|
||||
// MARK: - CKTeam
|
||||
|
||||
struct CKTeam {
|
||||
static let idKey = "teamId"
|
||||
static let nameKey = "name"
|
||||
static let abbreviationKey = "abbreviation"
|
||||
static let sportKey = "sport"
|
||||
static let cityKey = "city"
|
||||
static let stadiumRefKey = "stadiumRef"
|
||||
static let logoURLKey = "logoURL"
|
||||
static let primaryColorKey = "primaryColor"
|
||||
static let secondaryColorKey = "secondaryColor"
|
||||
|
||||
let record: CKRecord
|
||||
|
||||
init(record: CKRecord) {
|
||||
self.record = record
|
||||
}
|
||||
|
||||
init(team: Team, stadiumRecordID: CKRecord.ID) {
|
||||
let record = CKRecord(recordType: CKRecordType.team)
|
||||
record[CKTeam.idKey] = team.id.uuidString
|
||||
record[CKTeam.nameKey] = team.name
|
||||
record[CKTeam.abbreviationKey] = team.abbreviation
|
||||
record[CKTeam.sportKey] = team.sport.rawValue
|
||||
record[CKTeam.cityKey] = team.city
|
||||
record[CKTeam.stadiumRefKey] = CKRecord.Reference(recordID: stadiumRecordID, action: .none)
|
||||
record[CKTeam.logoURLKey] = team.logoURL?.absoluteString
|
||||
record[CKTeam.primaryColorKey] = team.primaryColor
|
||||
record[CKTeam.secondaryColorKey] = team.secondaryColor
|
||||
self.record = record
|
||||
}
|
||||
|
||||
var team: Team? {
|
||||
guard let idString = record[CKTeam.idKey] as? String,
|
||||
let id = UUID(uuidString: idString),
|
||||
let name = record[CKTeam.nameKey] as? String,
|
||||
let abbreviation = record[CKTeam.abbreviationKey] as? String,
|
||||
let sportRaw = record[CKTeam.sportKey] as? String,
|
||||
let sport = Sport(rawValue: sportRaw),
|
||||
let city = record[CKTeam.cityKey] as? String,
|
||||
let stadiumRef = record[CKTeam.stadiumRefKey] as? CKRecord.Reference,
|
||||
let stadiumIdString = stadiumRef.recordID.recordName.split(separator: ":").last,
|
||||
let stadiumId = UUID(uuidString: String(stadiumIdString))
|
||||
else { return nil }
|
||||
|
||||
let logoURL = (record[CKTeam.logoURLKey] as? String).flatMap { URL(string: $0) }
|
||||
|
||||
return Team(
|
||||
id: id,
|
||||
name: name,
|
||||
abbreviation: abbreviation,
|
||||
sport: sport,
|
||||
city: city,
|
||||
stadiumId: stadiumId,
|
||||
logoURL: logoURL,
|
||||
primaryColor: record[CKTeam.primaryColorKey] as? String,
|
||||
secondaryColor: record[CKTeam.secondaryColorKey] as? String
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CKStadium
|
||||
|
||||
struct CKStadium {
|
||||
static let idKey = "stadiumId"
|
||||
static let nameKey = "name"
|
||||
static let cityKey = "city"
|
||||
static let stateKey = "state"
|
||||
static let locationKey = "location"
|
||||
static let capacityKey = "capacity"
|
||||
static let yearOpenedKey = "yearOpened"
|
||||
static let imageURLKey = "imageURL"
|
||||
|
||||
let record: CKRecord
|
||||
|
||||
init(record: CKRecord) {
|
||||
self.record = record
|
||||
}
|
||||
|
||||
init(stadium: Stadium) {
|
||||
let record = CKRecord(recordType: CKRecordType.stadium)
|
||||
record[CKStadium.idKey] = stadium.id.uuidString
|
||||
record[CKStadium.nameKey] = stadium.name
|
||||
record[CKStadium.cityKey] = stadium.city
|
||||
record[CKStadium.stateKey] = stadium.state
|
||||
record[CKStadium.locationKey] = CLLocation(latitude: stadium.latitude, longitude: stadium.longitude)
|
||||
record[CKStadium.capacityKey] = stadium.capacity
|
||||
record[CKStadium.yearOpenedKey] = stadium.yearOpened
|
||||
record[CKStadium.imageURLKey] = stadium.imageURL?.absoluteString
|
||||
self.record = record
|
||||
}
|
||||
|
||||
var stadium: Stadium? {
|
||||
guard let idString = record[CKStadium.idKey] as? String,
|
||||
let id = UUID(uuidString: idString),
|
||||
let name = record[CKStadium.nameKey] as? String,
|
||||
let city = record[CKStadium.cityKey] as? String,
|
||||
let state = record[CKStadium.stateKey] as? String,
|
||||
let location = record[CKStadium.locationKey] as? CLLocation,
|
||||
let capacity = record[CKStadium.capacityKey] as? Int
|
||||
else { return nil }
|
||||
|
||||
let imageURL = (record[CKStadium.imageURLKey] as? String).flatMap { URL(string: $0) }
|
||||
|
||||
return Stadium(
|
||||
id: id,
|
||||
name: name,
|
||||
city: city,
|
||||
state: state,
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude,
|
||||
capacity: capacity,
|
||||
yearOpened: record[CKStadium.yearOpenedKey] as? Int,
|
||||
imageURL: imageURL
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CKGame
|
||||
|
||||
struct CKGame {
|
||||
static let idKey = "gameId"
|
||||
static let homeTeamRefKey = "homeTeamRef"
|
||||
static let awayTeamRefKey = "awayTeamRef"
|
||||
static let stadiumRefKey = "stadiumRef"
|
||||
static let dateTimeKey = "dateTime"
|
||||
static let sportKey = "sport"
|
||||
static let seasonKey = "season"
|
||||
static let isPlayoffKey = "isPlayoff"
|
||||
static let broadcastInfoKey = "broadcastInfo"
|
||||
|
||||
let record: CKRecord
|
||||
|
||||
init(record: CKRecord) {
|
||||
self.record = record
|
||||
}
|
||||
|
||||
init(game: Game, homeTeamRecordID: CKRecord.ID, awayTeamRecordID: CKRecord.ID, stadiumRecordID: CKRecord.ID) {
|
||||
let record = CKRecord(recordType: CKRecordType.game)
|
||||
record[CKGame.idKey] = game.id.uuidString
|
||||
record[CKGame.homeTeamRefKey] = CKRecord.Reference(recordID: homeTeamRecordID, action: .none)
|
||||
record[CKGame.awayTeamRefKey] = CKRecord.Reference(recordID: awayTeamRecordID, action: .none)
|
||||
record[CKGame.stadiumRefKey] = CKRecord.Reference(recordID: stadiumRecordID, action: .none)
|
||||
record[CKGame.dateTimeKey] = game.dateTime
|
||||
record[CKGame.sportKey] = game.sport.rawValue
|
||||
record[CKGame.seasonKey] = game.season
|
||||
record[CKGame.isPlayoffKey] = game.isPlayoff ? 1 : 0
|
||||
record[CKGame.broadcastInfoKey] = game.broadcastInfo
|
||||
self.record = record
|
||||
}
|
||||
|
||||
func game(homeTeamId: UUID, awayTeamId: UUID, stadiumId: UUID) -> Game? {
|
||||
guard let idString = record[CKGame.idKey] as? String,
|
||||
let id = UUID(uuidString: idString),
|
||||
let dateTime = record[CKGame.dateTimeKey] as? Date,
|
||||
let sportRaw = record[CKGame.sportKey] as? String,
|
||||
let sport = Sport(rawValue: sportRaw),
|
||||
let season = record[CKGame.seasonKey] as? String
|
||||
else { return nil }
|
||||
|
||||
return Game(
|
||||
id: id,
|
||||
homeTeamId: homeTeamId,
|
||||
awayTeamId: awayTeamId,
|
||||
stadiumId: stadiumId,
|
||||
dateTime: dateTime,
|
||||
sport: sport,
|
||||
season: season,
|
||||
isPlayoff: (record[CKGame.isPlayoffKey] as? Int) == 1,
|
||||
broadcastInfo: record[CKGame.broadcastInfoKey] as? String
|
||||
)
|
||||
}
|
||||
}
|
||||
94
SportsTime/Core/Models/Domain/Game.swift
Normal file
94
SportsTime/Core/Models/Domain/Game.swift
Normal file
@@ -0,0 +1,94 @@
|
||||
//
|
||||
// Game.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Game: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let homeTeamId: UUID
|
||||
let awayTeamId: UUID
|
||||
let stadiumId: UUID
|
||||
let dateTime: Date
|
||||
let sport: Sport
|
||||
let season: String
|
||||
let isPlayoff: Bool
|
||||
let broadcastInfo: String?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
homeTeamId: UUID,
|
||||
awayTeamId: UUID,
|
||||
stadiumId: UUID,
|
||||
dateTime: Date,
|
||||
sport: Sport,
|
||||
season: String,
|
||||
isPlayoff: Bool = false,
|
||||
broadcastInfo: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.homeTeamId = homeTeamId
|
||||
self.awayTeamId = awayTeamId
|
||||
self.stadiumId = stadiumId
|
||||
self.dateTime = dateTime
|
||||
self.sport = sport
|
||||
self.season = season
|
||||
self.isPlayoff = isPlayoff
|
||||
self.broadcastInfo = broadcastInfo
|
||||
}
|
||||
|
||||
var gameDate: Date {
|
||||
Calendar.current.startOfDay(for: dateTime)
|
||||
}
|
||||
|
||||
/// Alias for TripPlanningEngine compatibility
|
||||
var startTime: Date { dateTime }
|
||||
|
||||
var gameTime: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: dateTime)
|
||||
}
|
||||
|
||||
var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .medium
|
||||
return formatter.string(from: dateTime)
|
||||
}
|
||||
|
||||
var dayOfWeek: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE"
|
||||
return formatter.string(from: dateTime)
|
||||
}
|
||||
}
|
||||
|
||||
extension Game: Equatable {
|
||||
static func == (lhs: Game, rhs: Game) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Rich Game Model (with resolved references)
|
||||
|
||||
struct RichGame: Identifiable, Hashable {
|
||||
let game: Game
|
||||
let homeTeam: Team
|
||||
let awayTeam: Team
|
||||
let stadium: Stadium
|
||||
|
||||
var id: UUID { game.id }
|
||||
|
||||
var matchupDescription: String {
|
||||
"\(awayTeam.abbreviation) @ \(homeTeam.abbreviation)"
|
||||
}
|
||||
|
||||
var fullMatchupDescription: String {
|
||||
"\(awayTeam.fullName) at \(homeTeam.fullName)"
|
||||
}
|
||||
|
||||
var venueDescription: String {
|
||||
"\(stadium.name), \(stadium.city)"
|
||||
}
|
||||
}
|
||||
64
SportsTime/Core/Models/Domain/Sport.swift
Normal file
64
SportsTime/Core/Models/Domain/Sport.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// Sport.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum Sport: String, Codable, CaseIterable, Identifiable {
|
||||
case mlb = "MLB"
|
||||
case nba = "NBA"
|
||||
case nhl = "NHL"
|
||||
case nfl = "NFL"
|
||||
case mls = "MLS"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .mlb: return "Major League Baseball"
|
||||
case .nba: return "National Basketball Association"
|
||||
case .nhl: return "National Hockey League"
|
||||
case .nfl: return "National Football League"
|
||||
case .mls: return "Major League Soccer"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .mlb: return "baseball.fill"
|
||||
case .nba: return "basketball.fill"
|
||||
case .nhl: return "hockey.puck.fill"
|
||||
case .nfl: return "football.fill"
|
||||
case .mls: return "soccerball"
|
||||
}
|
||||
}
|
||||
|
||||
var seasonMonths: ClosedRange<Int> {
|
||||
switch self {
|
||||
case .mlb: return 3...10 // March - October
|
||||
case .nba: return 10...6 // October - June (wraps)
|
||||
case .nhl: return 10...6 // October - June (wraps)
|
||||
case .nfl: return 9...2 // September - February (wraps)
|
||||
case .mls: return 2...12 // February - December
|
||||
}
|
||||
}
|
||||
|
||||
func isInSeason(for date: Date) -> Bool {
|
||||
let calendar = Calendar.current
|
||||
let month = calendar.component(.month, from: date)
|
||||
|
||||
let range = seasonMonths
|
||||
if range.lowerBound <= range.upperBound {
|
||||
return range.contains(month)
|
||||
} else {
|
||||
// Season wraps around year boundary
|
||||
return month >= range.lowerBound || month <= range.upperBound
|
||||
}
|
||||
}
|
||||
|
||||
/// Currently supported sports for MVP
|
||||
static var supported: [Sport] {
|
||||
[.mlb, .nba, .nhl]
|
||||
}
|
||||
}
|
||||
68
SportsTime/Core/Models/Domain/Stadium.swift
Normal file
68
SportsTime/Core/Models/Domain/Stadium.swift
Normal file
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// Stadium.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
struct Stadium: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let city: String
|
||||
let state: String
|
||||
let latitude: Double
|
||||
let longitude: Double
|
||||
let capacity: Int
|
||||
let yearOpened: Int?
|
||||
let imageURL: URL?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
city: String,
|
||||
state: String,
|
||||
latitude: Double,
|
||||
longitude: Double,
|
||||
capacity: Int,
|
||||
yearOpened: Int? = nil,
|
||||
imageURL: URL? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.city = city
|
||||
self.state = state
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
self.capacity = capacity
|
||||
self.yearOpened = yearOpened
|
||||
self.imageURL = imageURL
|
||||
}
|
||||
|
||||
var location: CLLocation {
|
||||
CLLocation(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
|
||||
var coordinate: CLLocationCoordinate2D {
|
||||
CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
|
||||
}
|
||||
|
||||
var fullAddress: String {
|
||||
"\(city), \(state)"
|
||||
}
|
||||
|
||||
func distance(to other: Stadium) -> CLLocationDistance {
|
||||
location.distance(from: other.location)
|
||||
}
|
||||
|
||||
func distance(from coordinate: CLLocationCoordinate2D) -> CLLocationDistance {
|
||||
let otherLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||||
return location.distance(from: otherLocation)
|
||||
}
|
||||
}
|
||||
|
||||
extension Stadium: Equatable {
|
||||
static func == (lhs: Stadium, rhs: Stadium) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
50
SportsTime/Core/Models/Domain/Team.swift
Normal file
50
SportsTime/Core/Models/Domain/Team.swift
Normal file
@@ -0,0 +1,50 @@
|
||||
//
|
||||
// Team.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Team: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let abbreviation: String
|
||||
let sport: Sport
|
||||
let city: String
|
||||
let stadiumId: UUID
|
||||
let logoURL: URL?
|
||||
let primaryColor: String?
|
||||
let secondaryColor: String?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
abbreviation: String,
|
||||
sport: Sport,
|
||||
city: String,
|
||||
stadiumId: UUID,
|
||||
logoURL: URL? = nil,
|
||||
primaryColor: String? = nil,
|
||||
secondaryColor: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.abbreviation = abbreviation
|
||||
self.sport = sport
|
||||
self.city = city
|
||||
self.stadiumId = stadiumId
|
||||
self.logoURL = logoURL
|
||||
self.primaryColor = primaryColor
|
||||
self.secondaryColor = secondaryColor
|
||||
}
|
||||
|
||||
var fullName: String {
|
||||
"\(city) \(name)"
|
||||
}
|
||||
}
|
||||
|
||||
extension Team: Equatable {
|
||||
static func == (lhs: Team, rhs: Team) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
108
SportsTime/Core/Models/Domain/TravelSegment.swift
Normal file
108
SportsTime/Core/Models/Domain/TravelSegment.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// TravelSegment.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
struct TravelSegment: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let fromLocation: LocationInput
|
||||
let toLocation: LocationInput
|
||||
let travelMode: TravelMode
|
||||
let distanceMeters: Double
|
||||
let durationSeconds: Double
|
||||
let departureTime: Date
|
||||
let arrivalTime: Date
|
||||
let scenicScore: Double
|
||||
let evChargingStops: [EVChargingStop]
|
||||
let routePolyline: String?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
fromLocation: LocationInput,
|
||||
toLocation: LocationInput,
|
||||
travelMode: TravelMode,
|
||||
distanceMeters: Double,
|
||||
durationSeconds: Double,
|
||||
departureTime: Date,
|
||||
arrivalTime: Date,
|
||||
scenicScore: Double = 0.5,
|
||||
evChargingStops: [EVChargingStop] = [],
|
||||
routePolyline: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.fromLocation = fromLocation
|
||||
self.toLocation = toLocation
|
||||
self.travelMode = travelMode
|
||||
self.distanceMeters = distanceMeters
|
||||
self.durationSeconds = durationSeconds
|
||||
self.departureTime = departureTime
|
||||
self.arrivalTime = arrivalTime
|
||||
self.scenicScore = scenicScore
|
||||
self.evChargingStops = evChargingStops
|
||||
self.routePolyline = routePolyline
|
||||
}
|
||||
|
||||
var distanceMiles: Double { distanceMeters * 0.000621371 }
|
||||
var durationHours: Double { durationSeconds / 3600.0 }
|
||||
|
||||
/// Alias for TripPlanningEngine compatibility
|
||||
var estimatedDrivingHours: Double { durationHours }
|
||||
var estimatedDistanceMiles: Double { distanceMiles }
|
||||
|
||||
var formattedDistance: String {
|
||||
String(format: "%.0f mi", distanceMiles)
|
||||
}
|
||||
|
||||
var formattedDuration: String {
|
||||
let hours = Int(durationHours)
|
||||
let minutes = Int((durationHours - Double(hours)) * 60)
|
||||
if hours > 0 && minutes > 0 { return "\(hours)h \(minutes)m" }
|
||||
else if hours > 0 { return "\(hours)h" }
|
||||
else { return "\(minutes)m" }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - EV Charging Stop
|
||||
|
||||
struct EVChargingStop: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let location: LocationInput
|
||||
let chargerType: ChargerType
|
||||
let estimatedChargeTime: TimeInterval
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
location: LocationInput,
|
||||
chargerType: ChargerType = .dcFast,
|
||||
estimatedChargeTime: TimeInterval = 1800
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.location = location
|
||||
self.chargerType = chargerType
|
||||
self.estimatedChargeTime = estimatedChargeTime
|
||||
}
|
||||
|
||||
var formattedChargeTime: String {
|
||||
"\(Int(estimatedChargeTime / 60)) min"
|
||||
}
|
||||
}
|
||||
|
||||
enum ChargerType: String, Codable, CaseIterable {
|
||||
case level2
|
||||
case dcFast
|
||||
case supercharger
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .level2: return "Level 2"
|
||||
case .dcFast: return "DC Fast"
|
||||
case .supercharger: return "Supercharger"
|
||||
}
|
||||
}
|
||||
}
|
||||
184
SportsTime/Core/Models/Domain/Trip.swift
Normal file
184
SportsTime/Core/Models/Domain/Trip.swift
Normal file
@@ -0,0 +1,184 @@
|
||||
//
|
||||
// Trip.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Trip: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
let createdAt: Date
|
||||
var updatedAt: Date
|
||||
let preferences: TripPreferences
|
||||
var stops: [TripStop]
|
||||
var travelSegments: [TravelSegment]
|
||||
var totalGames: Int
|
||||
var totalDistanceMeters: Double
|
||||
var totalDrivingSeconds: Double
|
||||
var score: TripScore?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
createdAt: Date = Date(),
|
||||
updatedAt: Date = Date(),
|
||||
preferences: TripPreferences,
|
||||
stops: [TripStop] = [],
|
||||
travelSegments: [TravelSegment] = [],
|
||||
totalGames: Int = 0,
|
||||
totalDistanceMeters: Double = 0,
|
||||
totalDrivingSeconds: Double = 0,
|
||||
score: TripScore? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.preferences = preferences
|
||||
self.stops = stops
|
||||
self.travelSegments = travelSegments
|
||||
self.totalGames = totalGames
|
||||
self.totalDistanceMeters = totalDistanceMeters
|
||||
self.totalDrivingSeconds = totalDrivingSeconds
|
||||
self.score = score
|
||||
}
|
||||
|
||||
var totalDistanceMiles: Double { totalDistanceMeters * 0.000621371 }
|
||||
var totalDrivingHours: Double { totalDrivingSeconds / 3600.0 }
|
||||
|
||||
var tripDuration: Int {
|
||||
guard let first = stops.first, let last = stops.last else { return 0 }
|
||||
let days = Calendar.current.dateComponents([.day], from: first.arrivalDate, to: last.departureDate).day ?? 0
|
||||
return max(1, days + 1)
|
||||
}
|
||||
|
||||
var averageDrivingHoursPerDay: Double {
|
||||
guard tripDuration > 0 else { return 0 }
|
||||
return totalDrivingHours / Double(tripDuration)
|
||||
}
|
||||
|
||||
var cities: [String] { stops.map { $0.city } }
|
||||
var uniqueSports: Set<Sport> { preferences.sports }
|
||||
var startDate: Date { stops.first?.arrivalDate ?? preferences.startDate }
|
||||
var endDate: Date { stops.last?.departureDate ?? preferences.endDate }
|
||||
|
||||
var formattedDateRange: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d, yyyy"
|
||||
return "\(formatter.string(from: startDate)) - \(formatter.string(from: endDate))"
|
||||
}
|
||||
|
||||
var formattedTotalDistance: String { String(format: "%.0f miles", totalDistanceMiles) }
|
||||
|
||||
var formattedTotalDriving: String {
|
||||
let hours = Int(totalDrivingHours)
|
||||
let minutes = Int((totalDrivingHours - Double(hours)) * 60)
|
||||
return "\(hours)h \(minutes)m"
|
||||
}
|
||||
|
||||
func itineraryDays() -> [ItineraryDay] {
|
||||
var days: [ItineraryDay] = []
|
||||
let calendar = Calendar.current
|
||||
|
||||
guard let firstDate = stops.first?.arrivalDate else { return days }
|
||||
|
||||
// Find the last day with actual activity (last game date or last arrival)
|
||||
// Departure date is the day AFTER the last game, so we use day before departure
|
||||
let lastActivityDate: Date
|
||||
if let lastDeparture = stops.last?.departureDate {
|
||||
// Last activity is day before departure (departure is when you leave)
|
||||
lastActivityDate = calendar.date(byAdding: .day, value: -1, to: lastDeparture) ?? lastDeparture
|
||||
} else {
|
||||
lastActivityDate = stops.last?.arrivalDate ?? firstDate
|
||||
}
|
||||
|
||||
var currentDate = calendar.startOfDay(for: firstDate)
|
||||
let endDateNormalized = calendar.startOfDay(for: lastActivityDate)
|
||||
var dayNumber = 1
|
||||
|
||||
while currentDate <= endDateNormalized {
|
||||
let stopsForDay = stops.filter { stop in
|
||||
let arrivalDay = calendar.startOfDay(for: stop.arrivalDate)
|
||||
let departureDay = calendar.startOfDay(for: stop.departureDate)
|
||||
return currentDate >= arrivalDay && currentDate <= departureDay
|
||||
}
|
||||
|
||||
// Show travel segments that depart on this day
|
||||
// Travel TO the last city happens on the last game day (drive morning, watch game)
|
||||
let segmentsForDay = travelSegments.filter { segment in
|
||||
calendar.startOfDay(for: segment.departureTime) == currentDate
|
||||
}
|
||||
|
||||
days.append(ItineraryDay(
|
||||
dayNumber: dayNumber,
|
||||
date: currentDate,
|
||||
stops: stopsForDay,
|
||||
travelSegments: segmentsForDay
|
||||
))
|
||||
|
||||
currentDate = calendar.date(byAdding: .day, value: 1, to: currentDate)!
|
||||
dayNumber += 1
|
||||
}
|
||||
return days
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Score
|
||||
|
||||
struct TripScore: Codable, Hashable {
|
||||
let overallScore: Double
|
||||
let gameQualityScore: Double
|
||||
let routeEfficiencyScore: Double
|
||||
let leisureBalanceScore: Double
|
||||
let preferenceAlignmentScore: Double
|
||||
|
||||
var formattedOverallScore: String { String(format: "%.0f", overallScore) }
|
||||
|
||||
var scoreGrade: String {
|
||||
switch overallScore {
|
||||
case 90...100: return "A+"
|
||||
case 85..<90: return "A"
|
||||
case 80..<85: return "A-"
|
||||
case 75..<80: return "B+"
|
||||
case 70..<75: return "B"
|
||||
case 65..<70: return "B-"
|
||||
case 60..<65: return "C+"
|
||||
case 55..<60: return "C"
|
||||
default: return "C-"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Itinerary Day
|
||||
|
||||
struct ItineraryDay: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let dayNumber: Int
|
||||
let date: Date
|
||||
let stops: [TripStop]
|
||||
let travelSegments: [TravelSegment]
|
||||
|
||||
var formattedDate: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "EEEE, MMM d"
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
var isRestDay: Bool { stops.first?.isRestDay ?? false }
|
||||
var hasTravelSegment: Bool { !travelSegments.isEmpty }
|
||||
var gameIds: [UUID] { stops.flatMap { $0.games } }
|
||||
var hasGames: Bool { !gameIds.isEmpty }
|
||||
var primaryCity: String? { stops.first?.city }
|
||||
var totalDrivingHours: Double { travelSegments.reduce(0) { $0 + $1.durationHours } }
|
||||
}
|
||||
|
||||
// MARK: - Trip Status
|
||||
|
||||
enum TripStatus: String, Codable {
|
||||
case draft
|
||||
case planned
|
||||
case inProgress
|
||||
case completed
|
||||
case cancelled
|
||||
}
|
||||
284
SportsTime/Core/Models/Domain/TripPreferences.swift
Normal file
284
SportsTime/Core/Models/Domain/TripPreferences.swift
Normal file
@@ -0,0 +1,284 @@
|
||||
//
|
||||
// TripPreferences.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
// MARK: - Planning Mode
|
||||
|
||||
enum PlanningMode: String, Codable, CaseIterable, Identifiable {
|
||||
case dateRange // Start date + end date, find games in range
|
||||
case gameFirst // Pick games first, trip around those games
|
||||
case locations // Start/end locations, optional games along route
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .dateRange: return "By Dates"
|
||||
case .gameFirst: return "By Games"
|
||||
case .locations: return "By Route"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .dateRange: return "Find games within a date range"
|
||||
case .gameFirst: return "Build trip around specific games"
|
||||
case .locations: return "Plan route between locations"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .dateRange: return "calendar"
|
||||
case .gameFirst: return "sportscourt"
|
||||
case .locations: return "map"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Travel Mode
|
||||
|
||||
enum TravelMode: String, Codable, CaseIterable, Identifiable {
|
||||
case drive
|
||||
case fly
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .drive: return "Drive"
|
||||
case .fly: return "Fly"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .drive: return "car.fill"
|
||||
case .fly: return "airplane"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lodging Type
|
||||
|
||||
enum LodgingType: String, Codable, CaseIterable, Identifiable {
|
||||
case hotel
|
||||
case camperRV
|
||||
case airbnb
|
||||
case flexible
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .hotel: return "Hotels"
|
||||
case .camperRV: return "Camper / RV"
|
||||
case .airbnb: return "Airbnb"
|
||||
case .flexible: return "Flexible"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .hotel: return "building.2.fill"
|
||||
case .camperRV: return "bus.fill"
|
||||
case .airbnb: return "house.fill"
|
||||
case .flexible: return "questionmark.circle.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Leisure Level
|
||||
|
||||
enum LeisureLevel: String, Codable, CaseIterable, Identifiable {
|
||||
case packed
|
||||
case moderate
|
||||
case relaxed
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .packed: return "Packed"
|
||||
case .moderate: return "Moderate"
|
||||
case .relaxed: return "Relaxed"
|
||||
}
|
||||
}
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .packed: return "Maximize games, minimal downtime"
|
||||
case .moderate: return "Balance games with rest days"
|
||||
case .relaxed: return "Prioritize comfort, fewer games"
|
||||
}
|
||||
}
|
||||
|
||||
var restDaysPerWeek: Double {
|
||||
switch self {
|
||||
case .packed: return 0.5
|
||||
case .moderate: return 1.5
|
||||
case .relaxed: return 2.5
|
||||
}
|
||||
}
|
||||
|
||||
var maxGamesPerWeek: Int {
|
||||
switch self {
|
||||
case .packed: return 7
|
||||
case .moderate: return 5
|
||||
case .relaxed: return 3
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Preference
|
||||
|
||||
enum RoutePreference: String, Codable, CaseIterable, Identifiable {
|
||||
case direct
|
||||
case scenic
|
||||
case balanced
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .direct: return "Direct"
|
||||
case .scenic: return "Scenic"
|
||||
case .balanced: return "Balanced"
|
||||
}
|
||||
}
|
||||
|
||||
var scenicWeight: Double {
|
||||
switch self {
|
||||
case .direct: return 0.0
|
||||
case .scenic: return 1.0
|
||||
case .balanced: return 0.5
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Location Input
|
||||
|
||||
struct LocationInput: Codable, Hashable {
|
||||
let name: String
|
||||
let coordinate: CLLocationCoordinate2D?
|
||||
let address: String?
|
||||
|
||||
init(name: String, coordinate: CLLocationCoordinate2D? = nil, address: String? = nil) {
|
||||
self.name = name
|
||||
self.coordinate = coordinate
|
||||
self.address = address
|
||||
}
|
||||
|
||||
var isResolved: Bool { coordinate != nil }
|
||||
}
|
||||
|
||||
extension CLLocationCoordinate2D: Codable, Hashable {
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case latitude, longitude
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
let lat = try container.decode(Double.self, forKey: .latitude)
|
||||
let lon = try container.decode(Double.self, forKey: .longitude)
|
||||
self.init(latitude: lat, longitude: lon)
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(latitude, forKey: .latitude)
|
||||
try container.encode(longitude, forKey: .longitude)
|
||||
}
|
||||
|
||||
public func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(latitude)
|
||||
hasher.combine(longitude)
|
||||
}
|
||||
|
||||
public static func == (lhs: CLLocationCoordinate2D, rhs: CLLocationCoordinate2D) -> Bool {
|
||||
lhs.latitude == rhs.latitude && lhs.longitude == rhs.longitude
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Preferences
|
||||
|
||||
struct TripPreferences: Codable, Hashable {
|
||||
var planningMode: PlanningMode
|
||||
var startLocation: LocationInput?
|
||||
var endLocation: LocationInput?
|
||||
var sports: Set<Sport>
|
||||
var mustSeeGameIds: Set<UUID>
|
||||
var travelMode: TravelMode
|
||||
var startDate: Date
|
||||
var endDate: Date
|
||||
|
||||
var numberOfStops: Int?
|
||||
var tripDuration: Int?
|
||||
var leisureLevel: LeisureLevel
|
||||
|
||||
var mustStopLocations: [LocationInput]
|
||||
var preferredCities: [String]
|
||||
var routePreference: RoutePreference
|
||||
var needsEVCharging: Bool
|
||||
var lodgingType: LodgingType
|
||||
var numberOfDrivers: Int
|
||||
var maxDrivingHoursPerDriver: Double?
|
||||
var catchOtherSports: Bool
|
||||
|
||||
init(
|
||||
planningMode: PlanningMode = .dateRange,
|
||||
startLocation: LocationInput? = nil,
|
||||
endLocation: LocationInput? = nil,
|
||||
sports: Set<Sport> = [],
|
||||
mustSeeGameIds: Set<UUID> = [],
|
||||
travelMode: TravelMode = .drive,
|
||||
startDate: Date = Date(),
|
||||
endDate: Date = Date().addingTimeInterval(86400 * 7),
|
||||
numberOfStops: Int? = nil,
|
||||
tripDuration: Int? = nil,
|
||||
leisureLevel: LeisureLevel = .moderate,
|
||||
mustStopLocations: [LocationInput] = [],
|
||||
preferredCities: [String] = [],
|
||||
routePreference: RoutePreference = .balanced,
|
||||
needsEVCharging: Bool = false,
|
||||
lodgingType: LodgingType = .hotel,
|
||||
numberOfDrivers: Int = 1,
|
||||
maxDrivingHoursPerDriver: Double? = nil,
|
||||
catchOtherSports: Bool = false
|
||||
) {
|
||||
self.planningMode = planningMode
|
||||
self.startLocation = startLocation
|
||||
self.endLocation = endLocation
|
||||
self.sports = sports
|
||||
self.mustSeeGameIds = mustSeeGameIds
|
||||
self.travelMode = travelMode
|
||||
self.startDate = startDate
|
||||
self.endDate = endDate
|
||||
self.numberOfStops = numberOfStops
|
||||
self.tripDuration = tripDuration
|
||||
self.leisureLevel = leisureLevel
|
||||
self.mustStopLocations = mustStopLocations
|
||||
self.preferredCities = preferredCities
|
||||
self.routePreference = routePreference
|
||||
self.needsEVCharging = needsEVCharging
|
||||
self.lodgingType = lodgingType
|
||||
self.numberOfDrivers = numberOfDrivers
|
||||
self.maxDrivingHoursPerDriver = maxDrivingHoursPerDriver
|
||||
self.catchOtherSports = catchOtherSports
|
||||
}
|
||||
|
||||
var totalDriverHoursPerDay: Double {
|
||||
let maxPerDriver = maxDrivingHoursPerDriver ?? 8.0
|
||||
return maxPerDriver * Double(numberOfDrivers)
|
||||
}
|
||||
|
||||
var effectiveTripDuration: Int {
|
||||
if let duration = tripDuration { return duration }
|
||||
let days = Calendar.current.dateComponents([.day], from: startDate, to: endDate).day ?? 7
|
||||
return max(1, days)
|
||||
}
|
||||
}
|
||||
167
SportsTime/Core/Models/Domain/TripStop.swift
Normal file
167
SportsTime/Core/Models/Domain/TripStop.swift
Normal file
@@ -0,0 +1,167 @@
|
||||
//
|
||||
// TripStop.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
|
||||
struct TripStop: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let stopNumber: Int
|
||||
let city: String
|
||||
let state: String
|
||||
let coordinate: CLLocationCoordinate2D?
|
||||
let arrivalDate: Date
|
||||
let departureDate: Date
|
||||
let games: [UUID]
|
||||
let stadium: UUID?
|
||||
let lodging: LodgingSuggestion?
|
||||
let activities: [ActivitySuggestion]
|
||||
let isRestDay: Bool
|
||||
let notes: String?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
stopNumber: Int,
|
||||
city: String,
|
||||
state: String,
|
||||
coordinate: CLLocationCoordinate2D? = nil,
|
||||
arrivalDate: Date,
|
||||
departureDate: Date,
|
||||
games: [UUID] = [],
|
||||
stadium: UUID? = nil,
|
||||
lodging: LodgingSuggestion? = nil,
|
||||
activities: [ActivitySuggestion] = [],
|
||||
isRestDay: Bool = false,
|
||||
notes: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.stopNumber = stopNumber
|
||||
self.city = city
|
||||
self.state = state
|
||||
self.coordinate = coordinate
|
||||
self.arrivalDate = arrivalDate
|
||||
self.departureDate = departureDate
|
||||
self.games = games
|
||||
self.stadium = stadium
|
||||
self.lodging = lodging
|
||||
self.activities = activities
|
||||
self.isRestDay = isRestDay
|
||||
self.notes = notes
|
||||
}
|
||||
|
||||
var stayDuration: Int {
|
||||
let days = Calendar.current.dateComponents([.day], from: arrivalDate, to: departureDate).day ?? 1
|
||||
return max(1, days)
|
||||
}
|
||||
|
||||
var locationDescription: String { "\(city), \(state)" }
|
||||
var hasGames: Bool { !games.isEmpty }
|
||||
|
||||
var formattedDateRange: String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "MMM d"
|
||||
let start = formatter.string(from: arrivalDate)
|
||||
let end = formatter.string(from: departureDate)
|
||||
return stayDuration > 1 ? "\(start) - \(end)" : start
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Lodging Suggestion
|
||||
|
||||
struct LodgingSuggestion: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let type: LodgingType
|
||||
let address: String?
|
||||
let coordinate: CLLocationCoordinate2D?
|
||||
let priceRange: PriceRange?
|
||||
let distanceToVenue: Double?
|
||||
let rating: Double?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
type: LodgingType,
|
||||
address: String? = nil,
|
||||
coordinate: CLLocationCoordinate2D? = nil,
|
||||
priceRange: PriceRange? = nil,
|
||||
distanceToVenue: Double? = nil,
|
||||
rating: Double? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.type = type
|
||||
self.address = address
|
||||
self.coordinate = coordinate
|
||||
self.priceRange = priceRange
|
||||
self.distanceToVenue = distanceToVenue
|
||||
self.rating = rating
|
||||
}
|
||||
}
|
||||
|
||||
enum PriceRange: String, Codable, CaseIterable {
|
||||
case budget = "$"
|
||||
case moderate = "$$"
|
||||
case upscale = "$$$"
|
||||
case luxury = "$$$$"
|
||||
}
|
||||
|
||||
// MARK: - Activity Suggestion
|
||||
|
||||
struct ActivitySuggestion: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let name: String
|
||||
let description: String?
|
||||
let category: ActivityCategory
|
||||
let estimatedDuration: TimeInterval
|
||||
let location: LocationInput?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
description: String? = nil,
|
||||
category: ActivityCategory = .attraction,
|
||||
estimatedDuration: TimeInterval = 7200,
|
||||
location: LocationInput? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.category = category
|
||||
self.estimatedDuration = estimatedDuration
|
||||
self.location = location
|
||||
}
|
||||
}
|
||||
|
||||
enum ActivityCategory: String, Codable, CaseIterable {
|
||||
case attraction
|
||||
case food
|
||||
case entertainment
|
||||
case outdoors
|
||||
case shopping
|
||||
case culture
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .attraction: return "Attraction"
|
||||
case .food: return "Food & Drink"
|
||||
case .entertainment: return "Entertainment"
|
||||
case .outdoors: return "Outdoors"
|
||||
case .shopping: return "Shopping"
|
||||
case .culture: return "Culture"
|
||||
}
|
||||
}
|
||||
|
||||
var iconName: String {
|
||||
switch self {
|
||||
case .attraction: return "star.fill"
|
||||
case .food: return "fork.knife"
|
||||
case .entertainment: return "theatermasks.fill"
|
||||
case .outdoors: return "leaf.fill"
|
||||
case .shopping: return "bag.fill"
|
||||
case .culture: return "building.columns.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
198
SportsTime/Core/Models/Local/SavedTrip.swift
Normal file
198
SportsTime/Core/Models/Local/SavedTrip.swift
Normal file
@@ -0,0 +1,198 @@
|
||||
//
|
||||
// SavedTrip.swift
|
||||
// SportsTime
|
||||
//
|
||||
// SwiftData models for local persistence
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class SavedTrip {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var name: String
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var status: String
|
||||
var tripData: Data // Encoded Trip struct
|
||||
|
||||
@Relationship(deleteRule: .cascade)
|
||||
var votes: [TripVote]?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
name: String,
|
||||
createdAt: Date = Date(),
|
||||
updatedAt: Date = Date(),
|
||||
status: TripStatus = .planned,
|
||||
tripData: Data
|
||||
) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.createdAt = createdAt
|
||||
self.updatedAt = updatedAt
|
||||
self.status = status.rawValue
|
||||
self.tripData = tripData
|
||||
}
|
||||
|
||||
var trip: Trip? {
|
||||
try? JSONDecoder().decode(Trip.self, from: tripData)
|
||||
}
|
||||
|
||||
var tripStatus: TripStatus {
|
||||
TripStatus(rawValue: status) ?? .draft
|
||||
}
|
||||
|
||||
static func from(_ trip: Trip, status: TripStatus = .planned) -> SavedTrip? {
|
||||
guard let data = try? JSONEncoder().encode(trip) else { return nil }
|
||||
return SavedTrip(
|
||||
id: trip.id,
|
||||
name: trip.name,
|
||||
createdAt: trip.createdAt,
|
||||
updatedAt: trip.updatedAt,
|
||||
status: status,
|
||||
tripData: data
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Trip Vote (Phase 2)
|
||||
|
||||
@Model
|
||||
final class TripVote {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var tripId: UUID
|
||||
var voterId: String
|
||||
var voterName: String
|
||||
var gameVotes: Data // [UUID: Bool] encoded
|
||||
var routeVotes: Data // [String: Int] encoded
|
||||
var leisurePreference: String
|
||||
var createdAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
tripId: UUID,
|
||||
voterId: String,
|
||||
voterName: String,
|
||||
gameVotes: Data,
|
||||
routeVotes: Data,
|
||||
leisurePreference: LeisureLevel = .moderate,
|
||||
createdAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.tripId = tripId
|
||||
self.voterId = voterId
|
||||
self.voterName = voterName
|
||||
self.gameVotes = gameVotes
|
||||
self.routeVotes = routeVotes
|
||||
self.leisurePreference = leisurePreference.rawValue
|
||||
self.createdAt = createdAt
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - User Preferences
|
||||
|
||||
@Model
|
||||
final class UserPreferences {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var defaultSports: Data // [Sport] encoded
|
||||
var defaultTravelMode: String
|
||||
var defaultLeisureLevel: String
|
||||
var defaultLodgingType: String
|
||||
var homeLocation: Data? // LocationInput encoded
|
||||
var needsEVCharging: Bool
|
||||
var numberOfDrivers: Int
|
||||
var maxDrivingHours: Double?
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
defaultSports: [Sport] = Sport.supported,
|
||||
defaultTravelMode: TravelMode = .drive,
|
||||
defaultLeisureLevel: LeisureLevel = .moderate,
|
||||
defaultLodgingType: LodgingType = .hotel,
|
||||
homeLocation: LocationInput? = nil,
|
||||
needsEVCharging: Bool = false,
|
||||
numberOfDrivers: Int = 1,
|
||||
maxDrivingHours: Double? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.defaultSports = (try? JSONEncoder().encode(defaultSports)) ?? Data()
|
||||
self.defaultTravelMode = defaultTravelMode.rawValue
|
||||
self.defaultLeisureLevel = defaultLeisureLevel.rawValue
|
||||
self.defaultLodgingType = defaultLodgingType.rawValue
|
||||
self.homeLocation = try? JSONEncoder().encode(homeLocation)
|
||||
self.needsEVCharging = needsEVCharging
|
||||
self.numberOfDrivers = numberOfDrivers
|
||||
self.maxDrivingHours = maxDrivingHours
|
||||
}
|
||||
|
||||
var sports: [Sport] {
|
||||
(try? JSONDecoder().decode([Sport].self, from: defaultSports)) ?? Sport.supported
|
||||
}
|
||||
|
||||
var travelMode: TravelMode {
|
||||
TravelMode(rawValue: defaultTravelMode) ?? .drive
|
||||
}
|
||||
|
||||
var leisureLevel: LeisureLevel {
|
||||
LeisureLevel(rawValue: defaultLeisureLevel) ?? .moderate
|
||||
}
|
||||
|
||||
var lodgingType: LodgingType {
|
||||
LodgingType(rawValue: defaultLodgingType) ?? .hotel
|
||||
}
|
||||
|
||||
var home: LocationInput? {
|
||||
guard let data = homeLocation else { return nil }
|
||||
return try? JSONDecoder().decode(LocationInput.self, from: data)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cached Schedule
|
||||
|
||||
@Model
|
||||
final class CachedSchedule {
|
||||
@Attribute(.unique) var id: UUID
|
||||
var sport: String
|
||||
var season: String
|
||||
var lastUpdated: Date
|
||||
var gamesData: Data // [Game] encoded
|
||||
var teamsData: Data // [Team] encoded
|
||||
var stadiumsData: Data // [Stadium] encoded
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
sport: Sport,
|
||||
season: String,
|
||||
lastUpdated: Date = Date(),
|
||||
games: [Game],
|
||||
teams: [Team],
|
||||
stadiums: [Stadium]
|
||||
) {
|
||||
self.id = id
|
||||
self.sport = sport.rawValue
|
||||
self.season = season
|
||||
self.lastUpdated = lastUpdated
|
||||
self.gamesData = (try? JSONEncoder().encode(games)) ?? Data()
|
||||
self.teamsData = (try? JSONEncoder().encode(teams)) ?? Data()
|
||||
self.stadiumsData = (try? JSONEncoder().encode(stadiums)) ?? Data()
|
||||
}
|
||||
|
||||
var games: [Game] {
|
||||
(try? JSONDecoder().decode([Game].self, from: gamesData)) ?? []
|
||||
}
|
||||
|
||||
var teams: [Team] {
|
||||
(try? JSONDecoder().decode([Team].self, from: teamsData)) ?? []
|
||||
}
|
||||
|
||||
var stadiums: [Stadium] {
|
||||
(try? JSONDecoder().decode([Stadium].self, from: stadiumsData)) ?? []
|
||||
}
|
||||
|
||||
var isStale: Bool {
|
||||
let staleThreshold: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||
return Date().timeIntervalSince(lastUpdated) > staleThreshold
|
||||
}
|
||||
}
|
||||
108
SportsTime/Core/Services/CloudKitDataProvider.swift
Normal file
108
SportsTime/Core/Services/CloudKitDataProvider.swift
Normal file
@@ -0,0 +1,108 @@
|
||||
//
|
||||
// CloudKitDataProvider.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Wraps CloudKitService to conform to DataProvider protocol
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
actor CloudKitDataProvider: DataProvider {
|
||||
|
||||
private let cloudKit = CloudKitService.shared
|
||||
|
||||
// MARK: - Availability
|
||||
|
||||
func checkAvailability() async throws {
|
||||
try await cloudKit.checkAvailabilityWithError()
|
||||
}
|
||||
|
||||
// MARK: - DataProvider Protocol
|
||||
|
||||
func fetchTeams(for sport: Sport) async throws -> [Team] {
|
||||
do {
|
||||
try await checkAvailability()
|
||||
return try await cloudKit.fetchTeams(for: sport)
|
||||
} catch {
|
||||
throw CloudKitError.from(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAllTeams() async throws -> [Team] {
|
||||
do {
|
||||
try await checkAvailability()
|
||||
var allTeams: [Team] = []
|
||||
for sport in Sport.supported {
|
||||
let teams = try await cloudKit.fetchTeams(for: sport)
|
||||
allTeams.append(contentsOf: teams)
|
||||
}
|
||||
return allTeams
|
||||
} catch {
|
||||
throw CloudKitError.from(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchStadiums() async throws -> [Stadium] {
|
||||
do {
|
||||
try await checkAvailability()
|
||||
return try await cloudKit.fetchStadiums()
|
||||
} catch {
|
||||
throw CloudKitError.from(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
|
||||
do {
|
||||
try await checkAvailability()
|
||||
return try await cloudKit.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
||||
} catch {
|
||||
throw CloudKitError.from(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGame(by id: UUID) async throws -> Game? {
|
||||
do {
|
||||
try await checkAvailability()
|
||||
return try await cloudKit.fetchGame(by: id)
|
||||
} catch {
|
||||
throw CloudKitError.from(error)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
|
||||
do {
|
||||
try await checkAvailability()
|
||||
|
||||
// Fetch all required data
|
||||
async let gamesTask = cloudKit.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
||||
async let teamsTask = fetchAllTeamsInternal()
|
||||
async let stadiumsTask = cloudKit.fetchStadiums()
|
||||
|
||||
let (games, teams, stadiums) = try await (gamesTask, teamsTask, stadiumsTask)
|
||||
|
||||
let teamsById = Dictionary(uniqueKeysWithValues: teams.map { ($0.id, $0) })
|
||||
let stadiumsById = Dictionary(uniqueKeysWithValues: stadiums.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)
|
||||
}
|
||||
} catch {
|
||||
throw CloudKitError.from(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Internal helper to avoid duplicate availability checks
|
||||
private func fetchAllTeamsInternal() async throws -> [Team] {
|
||||
var allTeams: [Team] = []
|
||||
for sport in Sport.supported {
|
||||
let teams = try await cloudKit.fetchTeams(for: sport)
|
||||
allTeams.append(contentsOf: teams)
|
||||
}
|
||||
return allTeams
|
||||
}
|
||||
}
|
||||
211
SportsTime/Core/Services/CloudKitService.swift
Normal file
211
SportsTime/Core/Services/CloudKitService.swift
Normal file
@@ -0,0 +1,211 @@
|
||||
//
|
||||
// CloudKitService.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
// MARK: - CloudKit Errors
|
||||
|
||||
enum CloudKitError: Error, LocalizedError {
|
||||
case notSignedIn
|
||||
case networkUnavailable
|
||||
case serverError(String)
|
||||
case quotaExceeded
|
||||
case permissionDenied
|
||||
case recordNotFound
|
||||
case unknown(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notSignedIn:
|
||||
return "Please sign in to iCloud in Settings to sync data."
|
||||
case .networkUnavailable:
|
||||
return "Unable to connect to the server. Check your internet connection."
|
||||
case .serverError(let message):
|
||||
return "Server error: \(message)"
|
||||
case .quotaExceeded:
|
||||
return "iCloud storage quota exceeded."
|
||||
case .permissionDenied:
|
||||
return "Permission denied. Check your iCloud settings."
|
||||
case .recordNotFound:
|
||||
return "Data not found."
|
||||
case .unknown(let error):
|
||||
return "An unexpected error occurred: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
static func from(_ error: Error) -> CloudKitError {
|
||||
if let ckError = error as? CKError {
|
||||
switch ckError.code {
|
||||
case .notAuthenticated:
|
||||
return .notSignedIn
|
||||
case .networkUnavailable, .networkFailure:
|
||||
return .networkUnavailable
|
||||
case .serverResponseLost:
|
||||
return .serverError("Connection lost")
|
||||
case .quotaExceeded:
|
||||
return .quotaExceeded
|
||||
case .permissionFailure:
|
||||
return .permissionDenied
|
||||
case .unknownItem:
|
||||
return .recordNotFound
|
||||
default:
|
||||
return .serverError(ckError.localizedDescription)
|
||||
}
|
||||
}
|
||||
return .unknown(error)
|
||||
}
|
||||
}
|
||||
|
||||
actor CloudKitService {
|
||||
static let shared = CloudKitService()
|
||||
|
||||
private let container: CKContainer
|
||||
private let publicDatabase: CKDatabase
|
||||
|
||||
private init() {
|
||||
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
||||
self.publicDatabase = container.publicCloudDatabase
|
||||
}
|
||||
|
||||
// MARK: - Availability Check
|
||||
|
||||
func isAvailable() async -> Bool {
|
||||
let status = await checkAccountStatus()
|
||||
return status == .available
|
||||
}
|
||||
|
||||
func checkAvailabilityWithError() async throws {
|
||||
let status = await checkAccountStatus()
|
||||
switch status {
|
||||
case .available:
|
||||
return
|
||||
case .noAccount:
|
||||
throw CloudKitError.notSignedIn
|
||||
case .restricted:
|
||||
throw CloudKitError.permissionDenied
|
||||
case .couldNotDetermine:
|
||||
throw CloudKitError.networkUnavailable
|
||||
case .temporarilyUnavailable:
|
||||
throw CloudKitError.networkUnavailable
|
||||
@unknown default:
|
||||
throw CloudKitError.networkUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Fetch Operations
|
||||
|
||||
func fetchTeams(for sport: Sport) async throws -> [Team] {
|
||||
let predicate = NSPredicate(format: "sport == %@", sport.rawValue)
|
||||
let query = CKQuery(recordType: CKRecordType.team, 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 CKTeam(record: record).team
|
||||
}
|
||||
}
|
||||
|
||||
func fetchStadiums() async throws -> [Stadium] {
|
||||
let predicate = NSPredicate(value: true)
|
||||
let query = CKQuery(recordType: CKRecordType.stadium, 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 CKStadium(record: record).stadium
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGames(
|
||||
sports: Set<Sport>,
|
||||
startDate: Date,
|
||||
endDate: Date
|
||||
) async throws -> [Game] {
|
||||
var allGames: [Game] = []
|
||||
|
||||
for sport in sports {
|
||||
let predicate = NSPredicate(
|
||||
format: "sport == %@ AND dateTime >= %@ AND dateTime <= %@",
|
||||
sport.rawValue,
|
||||
startDate as NSDate,
|
||||
endDate as NSDate
|
||||
)
|
||||
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
|
||||
let games = results.compactMap { result -> Game? in
|
||||
guard case .success(let record) = result.1 else { return nil }
|
||||
let ckGame = CKGame(record: record)
|
||||
|
||||
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
|
||||
let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference,
|
||||
let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference,
|
||||
let homeId = UUID(uuidString: homeRef.recordID.recordName),
|
||||
let awayId = UUID(uuidString: awayRef.recordID.recordName),
|
||||
let stadiumId = UUID(uuidString: stadiumRef.recordID.recordName)
|
||||
else { return nil }
|
||||
|
||||
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
|
||||
}
|
||||
|
||||
allGames.append(contentsOf: games)
|
||||
}
|
||||
|
||||
return allGames.sorted { $0.dateTime < $1.dateTime }
|
||||
}
|
||||
|
||||
func fetchGame(by id: UUID) async throws -> Game? {
|
||||
let predicate = NSPredicate(format: "gameId == %@", id.uuidString)
|
||||
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
|
||||
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
|
||||
guard let result = results.first,
|
||||
case .success(let record) = result.1 else { return nil }
|
||||
|
||||
let ckGame = CKGame(record: record)
|
||||
|
||||
guard let homeRef = record[CKGame.homeTeamRefKey] as? CKRecord.Reference,
|
||||
let awayRef = record[CKGame.awayTeamRefKey] as? CKRecord.Reference,
|
||||
let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference,
|
||||
let homeId = UUID(uuidString: homeRef.recordID.recordName),
|
||||
let awayId = UUID(uuidString: awayRef.recordID.recordName),
|
||||
let stadiumId = UUID(uuidString: stadiumRef.recordID.recordName)
|
||||
else { return nil }
|
||||
|
||||
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
|
||||
}
|
||||
|
||||
// MARK: - Sync Status
|
||||
|
||||
func checkAccountStatus() async -> CKAccountStatus {
|
||||
do {
|
||||
return try await container.accountStatus()
|
||||
} catch {
|
||||
return .couldNotDetermine
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Subscription (for schedule updates)
|
||||
|
||||
func subscribeToScheduleUpdates() async throws {
|
||||
let subscription = CKQuerySubscription(
|
||||
recordType: CKRecordType.game,
|
||||
predicate: NSPredicate(value: true),
|
||||
subscriptionID: "game-updates",
|
||||
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
||||
)
|
||||
|
||||
let notification = CKSubscription.NotificationInfo()
|
||||
notification.shouldSendContentAvailable = true
|
||||
subscription.notificationInfo = notification
|
||||
|
||||
try await publicDatabase.save(subscription)
|
||||
}
|
||||
}
|
||||
132
SportsTime/Core/Services/DataProvider.swift
Normal file
132
SportsTime/Core/Services/DataProvider.swift
Normal file
@@ -0,0 +1,132 @@
|
||||
//
|
||||
// DataProvider.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
/// Protocol defining data operations for teams, stadiums, and games
|
||||
protocol DataProvider: Sendable {
|
||||
func fetchTeams(for sport: Sport) async throws -> [Team]
|
||||
func fetchAllTeams() async throws -> [Team]
|
||||
func fetchStadiums() async throws -> [Stadium]
|
||||
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game]
|
||||
func fetchGame(by id: UUID) async throws -> Game?
|
||||
|
||||
// Resolved data (with team/stadium references)
|
||||
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame]
|
||||
}
|
||||
|
||||
/// Environment-aware data provider that switches between stub and CloudKit
|
||||
@MainActor
|
||||
final class AppDataProvider: ObservableObject {
|
||||
static let shared = AppDataProvider()
|
||||
|
||||
private let provider: any DataProvider
|
||||
|
||||
@Published private(set) var teams: [Team] = []
|
||||
@Published private(set) var stadiums: [Stadium] = []
|
||||
@Published private(set) var isLoading = false
|
||||
@Published private(set) var error: Error?
|
||||
@Published private(set) var errorMessage: String?
|
||||
@Published private(set) var isUsingStubData: Bool
|
||||
|
||||
private var teamsById: [UUID: Team] = [:]
|
||||
private var stadiumsById: [UUID: Stadium] = [:]
|
||||
|
||||
private init() {
|
||||
#if targetEnvironment(simulator)
|
||||
self.provider = StubDataProvider()
|
||||
self.isUsingStubData = true
|
||||
print("📱 Using StubDataProvider (Simulator)")
|
||||
#else
|
||||
self.provider = CloudKitDataProvider()
|
||||
self.isUsingStubData = false
|
||||
print("☁️ Using CloudKitDataProvider (Device)")
|
||||
#endif
|
||||
}
|
||||
|
||||
// MARK: - Data Loading
|
||||
|
||||
func loadInitialData() async {
|
||||
isLoading = true
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
async let teamsTask = provider.fetchAllTeams()
|
||||
async let stadiumsTask = provider.fetchStadiums()
|
||||
|
||||
let (loadedTeams, loadedStadiums) = try await (teamsTask, stadiumsTask)
|
||||
|
||||
self.teams = loadedTeams
|
||||
self.stadiums = loadedStadiums
|
||||
|
||||
// Build lookup dictionaries
|
||||
self.teamsById = Dictionary(uniqueKeysWithValues: loadedTeams.map { ($0.id, $0) })
|
||||
self.stadiumsById = Dictionary(uniqueKeysWithValues: loadedStadiums.map { ($0.id, $0) })
|
||||
|
||||
print("✅ Loaded \(teams.count) teams, \(stadiums.count) stadiums")
|
||||
} catch let cloudKitError as CloudKitError {
|
||||
self.error = cloudKitError
|
||||
self.errorMessage = cloudKitError.errorDescription
|
||||
print("❌ CloudKit error: \(cloudKitError.errorDescription ?? "Unknown")")
|
||||
} catch {
|
||||
self.error = error
|
||||
self.errorMessage = error.localizedDescription
|
||||
print("❌ Failed to load data: \(error)")
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
func clearError() {
|
||||
error = nil
|
||||
errorMessage = nil
|
||||
}
|
||||
|
||||
func retry() async {
|
||||
await loadInitialData()
|
||||
}
|
||||
|
||||
// MARK: - Data Access
|
||||
|
||||
func team(for id: UUID) -> Team? {
|
||||
teamsById[id]
|
||||
}
|
||||
|
||||
func stadium(for id: UUID) -> Stadium? {
|
||||
stadiumsById[id]
|
||||
}
|
||||
|
||||
func teams(for sport: Sport) -> [Team] {
|
||||
teams.filter { $0.sport == sport }
|
||||
}
|
||||
|
||||
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
|
||||
try await provider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
||||
}
|
||||
|
||||
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
|
||||
let games = try await provider.fetchGames(sports: sports, startDate: startDate, endDate: endDate)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func richGame(from game: Game) -> RichGame? {
|
||||
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)
|
||||
}
|
||||
}
|
||||
218
SportsTime/Core/Services/LocationPermissionManager.swift
Normal file
218
SportsTime/Core/Services/LocationPermissionManager.swift
Normal file
@@ -0,0 +1,218 @@
|
||||
//
|
||||
// LocationPermissionManager.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Manages location permission requests and status
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class LocationPermissionManager: NSObject {
|
||||
static let shared = LocationPermissionManager()
|
||||
|
||||
private(set) var authorizationStatus: CLAuthorizationStatus = .notDetermined
|
||||
private(set) var currentLocation: CLLocation?
|
||||
private(set) var isRequestingPermission = false
|
||||
|
||||
private let locationManager = CLLocationManager()
|
||||
|
||||
override private init() {
|
||||
super.init()
|
||||
locationManager.delegate = self
|
||||
authorizationStatus = locationManager.authorizationStatus
|
||||
}
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
var isAuthorized: Bool {
|
||||
authorizationStatus == .authorizedWhenInUse || authorizationStatus == .authorizedAlways
|
||||
}
|
||||
|
||||
var needsPermission: Bool {
|
||||
authorizationStatus == .notDetermined
|
||||
}
|
||||
|
||||
var isDenied: Bool {
|
||||
authorizationStatus == .denied || authorizationStatus == .restricted
|
||||
}
|
||||
|
||||
var statusMessage: String {
|
||||
switch authorizationStatus {
|
||||
case .notDetermined:
|
||||
return "Location access helps find nearby stadiums and optimize your route."
|
||||
case .restricted:
|
||||
return "Location access is restricted on this device."
|
||||
case .denied:
|
||||
return "Location access was denied. Enable it in Settings to use this feature."
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
return "Location access granted."
|
||||
@unknown default:
|
||||
return "Unknown location status."
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Actions
|
||||
|
||||
func requestPermission() {
|
||||
guard authorizationStatus == .notDetermined else { return }
|
||||
isRequestingPermission = true
|
||||
locationManager.requestWhenInUseAuthorization()
|
||||
}
|
||||
|
||||
func requestCurrentLocation() {
|
||||
guard isAuthorized else { return }
|
||||
locationManager.requestLocation()
|
||||
}
|
||||
|
||||
func openSettings() {
|
||||
guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { return }
|
||||
UIApplication.shared.open(settingsURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CLLocationManagerDelegate
|
||||
|
||||
extension LocationPermissionManager: CLLocationManagerDelegate {
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
Task { @MainActor in
|
||||
self.authorizationStatus = manager.authorizationStatus
|
||||
self.isRequestingPermission = false
|
||||
|
||||
// Auto-request location if newly authorized
|
||||
if self.isAuthorized {
|
||||
self.requestCurrentLocation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
Task { @MainActor in
|
||||
self.currentLocation = locations.last
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
Task { @MainActor in
|
||||
print("Location error: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Location Permission View
|
||||
|
||||
struct LocationPermissionView: View {
|
||||
@Bindable var manager = LocationPermissionManager.shared
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
Image(systemName: "location.circle.fill")
|
||||
.font(.system(size: 60))
|
||||
.foregroundStyle(.blue)
|
||||
|
||||
Text("Enable Location")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
|
||||
Text(manager.statusMessage)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.padding(.horizontal)
|
||||
|
||||
if manager.needsPermission {
|
||||
Button {
|
||||
manager.requestPermission()
|
||||
} label: {
|
||||
Text("Allow Location Access")
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
} else if manager.isDenied {
|
||||
Button {
|
||||
manager.openSettings()
|
||||
} label: {
|
||||
Text("Open Settings")
|
||||
.fontWeight(.semibold)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(Color.blue)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
} else if manager.isAuthorized {
|
||||
Label("Location Enabled", systemImage: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Location Permission Banner
|
||||
|
||||
struct LocationPermissionBanner: View {
|
||||
@Bindable var manager = LocationPermissionManager.shared
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
var body: some View {
|
||||
if manager.needsPermission || manager.isDenied {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "location.slash")
|
||||
.foregroundStyle(.orange)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Location Not Available")
|
||||
.font(.subheadline)
|
||||
.fontWeight(.medium)
|
||||
Text(manager.needsPermission ? "Enable for better route planning" : "Tap to enable in Settings")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button {
|
||||
if manager.needsPermission {
|
||||
manager.requestPermission()
|
||||
} else {
|
||||
manager.openSettings()
|
||||
}
|
||||
} label: {
|
||||
Text(manager.needsPermission ? "Enable" : "Settings")
|
||||
.font(.caption)
|
||||
.fontWeight(.semibold)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.blue)
|
||||
.foregroundStyle(.white)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Button {
|
||||
isPresented = false
|
||||
} label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.secondarySystemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
LocationPermissionView()
|
||||
}
|
||||
192
SportsTime/Core/Services/LocationService.swift
Normal file
192
SportsTime/Core/Services/LocationService.swift
Normal file
@@ -0,0 +1,192 @@
|
||||
//
|
||||
// LocationService.swift
|
||||
// SportsTime
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreLocation
|
||||
import MapKit
|
||||
|
||||
actor LocationService {
|
||||
static let shared = LocationService()
|
||||
|
||||
private let geocoder = CLGeocoder()
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - Geocoding
|
||||
|
||||
func geocode(_ address: String) async throws -> CLLocationCoordinate2D? {
|
||||
let placemarks = try await geocoder.geocodeAddressString(address)
|
||||
return placemarks.first?.location?.coordinate
|
||||
}
|
||||
|
||||
func reverseGeocode(_ coordinate: CLLocationCoordinate2D) async throws -> String? {
|
||||
let location = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||||
let placemarks = try await geocoder.reverseGeocodeLocation(location)
|
||||
|
||||
guard let placemark = placemarks.first else { return nil }
|
||||
|
||||
var components: [String] = []
|
||||
if let city = placemark.locality { components.append(city) }
|
||||
if let state = placemark.administrativeArea { components.append(state) }
|
||||
|
||||
return components.isEmpty ? nil : components.joined(separator: ", ")
|
||||
}
|
||||
|
||||
func resolveLocation(_ input: LocationInput) async throws -> LocationInput {
|
||||
if input.isResolved { return input }
|
||||
|
||||
let searchText = input.address ?? input.name
|
||||
guard let coordinate = try await geocode(searchText) else {
|
||||
throw LocationError.geocodingFailed
|
||||
}
|
||||
|
||||
return LocationInput(
|
||||
name: input.name,
|
||||
coordinate: coordinate,
|
||||
address: input.address
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Location Search
|
||||
|
||||
func searchLocations(_ query: String) async throws -> [LocationSearchResult] {
|
||||
guard !query.trimmingCharacters(in: .whitespaces).isEmpty else {
|
||||
return []
|
||||
}
|
||||
|
||||
let request = MKLocalSearch.Request()
|
||||
request.naturalLanguageQuery = query
|
||||
request.resultTypes = [.address, .pointOfInterest]
|
||||
|
||||
let search = MKLocalSearch(request: request)
|
||||
let response = try await search.start()
|
||||
|
||||
return response.mapItems.map { item in
|
||||
LocationSearchResult(
|
||||
name: item.name ?? "Unknown",
|
||||
address: formatAddress(item.placemark),
|
||||
coordinate: item.placemark.coordinate
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func formatAddress(_ placemark: MKPlacemark) -> String {
|
||||
var components: [String] = []
|
||||
if let city = placemark.locality { components.append(city) }
|
||||
if let state = placemark.administrativeArea { components.append(state) }
|
||||
if let country = placemark.country, country != "United States" {
|
||||
components.append(country)
|
||||
}
|
||||
return components.joined(separator: ", ")
|
||||
}
|
||||
|
||||
// MARK: - Distance Calculations
|
||||
|
||||
func calculateDistance(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) -> CLLocationDistance {
|
||||
let fromLocation = CLLocation(latitude: from.latitude, longitude: from.longitude)
|
||||
let toLocation = CLLocation(latitude: to.latitude, longitude: to.longitude)
|
||||
return fromLocation.distance(from: toLocation)
|
||||
}
|
||||
|
||||
func calculateDrivingRoute(
|
||||
from: CLLocationCoordinate2D,
|
||||
to: CLLocationCoordinate2D
|
||||
) async throws -> RouteInfo {
|
||||
let request = MKDirections.Request()
|
||||
request.source = MKMapItem(placemark: MKPlacemark(coordinate: from))
|
||||
request.destination = MKMapItem(placemark: MKPlacemark(coordinate: to))
|
||||
request.transportType = .automobile
|
||||
request.requestsAlternateRoutes = false
|
||||
|
||||
let directions = MKDirections(request: request)
|
||||
let response = try await directions.calculate()
|
||||
|
||||
guard let route = response.routes.first else {
|
||||
throw LocationError.routeNotFound
|
||||
}
|
||||
|
||||
return RouteInfo(
|
||||
distance: route.distance,
|
||||
expectedTravelTime: route.expectedTravelTime,
|
||||
polyline: route.polyline
|
||||
)
|
||||
}
|
||||
|
||||
func calculateDrivingMatrix(
|
||||
origins: [CLLocationCoordinate2D],
|
||||
destinations: [CLLocationCoordinate2D]
|
||||
) async throws -> [[RouteInfo?]] {
|
||||
var matrix: [[RouteInfo?]] = []
|
||||
|
||||
for origin in origins {
|
||||
var row: [RouteInfo?] = []
|
||||
for destination in destinations {
|
||||
do {
|
||||
let route = try await calculateDrivingRoute(from: origin, to: destination)
|
||||
row.append(route)
|
||||
} catch {
|
||||
row.append(nil)
|
||||
}
|
||||
}
|
||||
matrix.append(row)
|
||||
}
|
||||
|
||||
return matrix
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Info
|
||||
|
||||
struct RouteInfo {
|
||||
let distance: CLLocationDistance // meters
|
||||
let expectedTravelTime: TimeInterval // seconds
|
||||
let polyline: MKPolyline?
|
||||
|
||||
var distanceMiles: Double { distance * 0.000621371 }
|
||||
var travelTimeHours: Double { expectedTravelTime / 3600.0 }
|
||||
}
|
||||
|
||||
// MARK: - Location Search Result
|
||||
|
||||
struct LocationSearchResult: Identifiable, Hashable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let address: String
|
||||
let coordinate: CLLocationCoordinate2D
|
||||
|
||||
var displayName: String {
|
||||
if address.isEmpty || name == address {
|
||||
return name
|
||||
}
|
||||
return "\(name), \(address)"
|
||||
}
|
||||
|
||||
func toLocationInput() -> LocationInput {
|
||||
LocationInput(
|
||||
name: name,
|
||||
coordinate: coordinate,
|
||||
address: address.isEmpty ? nil : address
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum LocationError: Error, LocalizedError {
|
||||
case geocodingFailed
|
||||
case routeNotFound
|
||||
case permissionDenied
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .geocodingFailed: return "Unable to find location"
|
||||
case .routeNotFound: return "Unable to calculate route"
|
||||
case .permissionDenied: return "Location permission required"
|
||||
}
|
||||
}
|
||||
}
|
||||
385
SportsTime/Core/Services/StubDataProvider.swift
Normal file
385
SportsTime/Core/Services/StubDataProvider.swift
Normal file
@@ -0,0 +1,385 @@
|
||||
//
|
||||
// StubDataProvider.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Provides real data from bundled JSON files for Simulator testing
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
|
||||
actor StubDataProvider: DataProvider {
|
||||
|
||||
// MARK: - JSON Models
|
||||
|
||||
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 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?
|
||||
}
|
||||
|
||||
// MARK: - Cached Data
|
||||
|
||||
private var cachedGames: [Game]?
|
||||
private var cachedTeams: [Team]?
|
||||
private var cachedStadiums: [Stadium]?
|
||||
private var teamsByAbbrev: [String: Team] = [:]
|
||||
private var stadiumsByVenue: [String: Stadium] = [:]
|
||||
|
||||
// MARK: - DataProvider Protocol
|
||||
|
||||
func fetchTeams(for sport: Sport) async throws -> [Team] {
|
||||
try await loadAllDataIfNeeded()
|
||||
return cachedTeams?.filter { $0.sport == sport } ?? []
|
||||
}
|
||||
|
||||
func fetchAllTeams() async throws -> [Team] {
|
||||
try await loadAllDataIfNeeded()
|
||||
return cachedTeams ?? []
|
||||
}
|
||||
|
||||
func fetchStadiums() async throws -> [Stadium] {
|
||||
try await loadAllDataIfNeeded()
|
||||
return cachedStadiums ?? []
|
||||
}
|
||||
|
||||
func fetchGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [Game] {
|
||||
try await loadAllDataIfNeeded()
|
||||
|
||||
return (cachedGames ?? []).filter { game in
|
||||
sports.contains(game.sport) &&
|
||||
game.dateTime >= startDate &&
|
||||
game.dateTime <= endDate
|
||||
}
|
||||
}
|
||||
|
||||
func fetchGame(by id: UUID) async throws -> Game? {
|
||||
try await loadAllDataIfNeeded()
|
||||
return cachedGames?.first { $0.id == id }
|
||||
}
|
||||
|
||||
func fetchRichGames(sports: Set<Sport>, startDate: Date, endDate: Date) async throws -> [RichGame] {
|
||||
try await loadAllDataIfNeeded()
|
||||
|
||||
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: - Data Loading
|
||||
|
||||
private func loadAllDataIfNeeded() async throws {
|
||||
guard cachedGames == nil else { return }
|
||||
|
||||
// Load stadiums first
|
||||
let jsonStadiums = try loadStadiumsJSON()
|
||||
cachedStadiums = jsonStadiums.map { convertStadium($0) }
|
||||
|
||||
// Build stadium lookup by venue name
|
||||
for stadium in cachedStadiums ?? [] {
|
||||
stadiumsByVenue[stadium.name.lowercased()] = stadium
|
||||
}
|
||||
|
||||
// Load games and extract teams
|
||||
let jsonGames = try loadGamesJSON()
|
||||
|
||||
// Build teams from games data
|
||||
var teamsDict: [String: Team] = [:]
|
||||
for jsonGame in jsonGames {
|
||||
let sport = parseSport(jsonGame.sport)
|
||||
|
||||
// Home team
|
||||
let homeKey = "\(sport.rawValue)_\(jsonGame.home_team_abbrev)"
|
||||
if teamsDict[homeKey] == nil {
|
||||
let stadiumId = findStadiumId(venue: jsonGame.venue, sport: sport)
|
||||
let team = Team(
|
||||
id: deterministicUUID(from: homeKey),
|
||||
name: extractTeamName(from: jsonGame.home_team),
|
||||
abbreviation: jsonGame.home_team_abbrev,
|
||||
sport: sport,
|
||||
city: extractCity(from: jsonGame.home_team),
|
||||
stadiumId: stadiumId
|
||||
)
|
||||
teamsDict[homeKey] = team
|
||||
teamsByAbbrev[homeKey] = team
|
||||
}
|
||||
|
||||
// Away team
|
||||
let awayKey = "\(sport.rawValue)_\(jsonGame.away_team_abbrev)"
|
||||
if teamsDict[awayKey] == nil {
|
||||
// Away teams might not have a stadium in our data yet
|
||||
let team = Team(
|
||||
id: deterministicUUID(from: awayKey),
|
||||
name: extractTeamName(from: jsonGame.away_team),
|
||||
abbreviation: jsonGame.away_team_abbrev,
|
||||
sport: sport,
|
||||
city: extractCity(from: jsonGame.away_team),
|
||||
stadiumId: UUID() // Placeholder, will be updated when they're home team
|
||||
)
|
||||
teamsDict[awayKey] = team
|
||||
teamsByAbbrev[awayKey] = team
|
||||
}
|
||||
}
|
||||
cachedTeams = Array(teamsDict.values)
|
||||
|
||||
// Convert games (deduplicate by ID - JSON may have duplicate entries)
|
||||
var seenGameIds = Set<String>()
|
||||
let uniqueJsonGames = jsonGames.filter { game in
|
||||
if seenGameIds.contains(game.id) {
|
||||
return false
|
||||
}
|
||||
seenGameIds.insert(game.id)
|
||||
return true
|
||||
}
|
||||
cachedGames = uniqueJsonGames.compactMap { convertGame($0) }
|
||||
|
||||
print("StubDataProvider loaded: \(cachedGames?.count ?? 0) games, \(cachedTeams?.count ?? 0) teams, \(cachedStadiums?.count ?? 0) stadiums")
|
||||
}
|
||||
|
||||
private func loadGamesJSON() throws -> [JSONGame] {
|
||||
guard let url = Bundle.main.url(forResource: "games", withExtension: "json") else {
|
||||
print("Warning: games.json not found in bundle")
|
||||
return []
|
||||
}
|
||||
let data = try Data(contentsOf: url)
|
||||
do {
|
||||
return try JSONDecoder().decode([JSONGame].self, from: data)
|
||||
} catch let DecodingError.keyNotFound(key, context) {
|
||||
print("❌ Games JSON missing key '\(key.stringValue)' at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
|
||||
throw DecodingError.keyNotFound(key, context)
|
||||
} catch let DecodingError.typeMismatch(type, context) {
|
||||
print("❌ Games JSON type mismatch for \(type) at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
|
||||
throw DecodingError.typeMismatch(type, context)
|
||||
} catch {
|
||||
print("❌ Games JSON decode error: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func loadStadiumsJSON() throws -> [JSONStadium] {
|
||||
guard let url = Bundle.main.url(forResource: "stadiums", withExtension: "json") else {
|
||||
print("Warning: stadiums.json not found in bundle")
|
||||
return []
|
||||
}
|
||||
let data = try Data(contentsOf: url)
|
||||
do {
|
||||
return try JSONDecoder().decode([JSONStadium].self, from: data)
|
||||
} catch let DecodingError.keyNotFound(key, context) {
|
||||
print("❌ Stadiums JSON missing key '\(key.stringValue)' at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
|
||||
throw DecodingError.keyNotFound(key, context)
|
||||
} catch let DecodingError.typeMismatch(type, context) {
|
||||
print("❌ Stadiums JSON type mismatch for \(type) at path: \(context.codingPath.map { $0.stringValue }.joined(separator: "."))")
|
||||
throw DecodingError.typeMismatch(type, context)
|
||||
} catch {
|
||||
print("❌ Stadiums JSON decode error: \(error)")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Conversion Helpers
|
||||
|
||||
private func convertStadium(_ json: JSONStadium) -> Stadium {
|
||||
Stadium(
|
||||
id: deterministicUUID(from: json.id),
|
||||
name: json.name,
|
||||
city: json.city,
|
||||
state: json.state.isEmpty ? stateFromCity(json.city) : json.state,
|
||||
latitude: json.latitude,
|
||||
longitude: json.longitude,
|
||||
capacity: json.capacity,
|
||||
yearOpened: json.year_opened
|
||||
)
|
||||
}
|
||||
|
||||
private func convertGame(_ json: JSONGame) -> Game? {
|
||||
let sport = parseSport(json.sport)
|
||||
|
||||
let homeKey = "\(sport.rawValue)_\(json.home_team_abbrev)"
|
||||
let awayKey = "\(sport.rawValue)_\(json.away_team_abbrev)"
|
||||
|
||||
guard let homeTeam = teamsByAbbrev[homeKey],
|
||||
let awayTeam = teamsByAbbrev[awayKey] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let stadiumId = findStadiumId(venue: json.venue, sport: sport)
|
||||
|
||||
guard let dateTime = parseDateTime(date: json.date, time: json.time ?? "7:00p") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return Game(
|
||||
id: deterministicUUID(from: json.id),
|
||||
homeTeamId: homeTeam.id,
|
||||
awayTeamId: awayTeam.id,
|
||||
stadiumId: stadiumId,
|
||||
dateTime: dateTime,
|
||||
sport: sport,
|
||||
season: json.season,
|
||||
isPlayoff: json.is_playoff,
|
||||
broadcastInfo: json.broadcast
|
||||
)
|
||||
}
|
||||
|
||||
private func parseSport(_ sport: String) -> Sport {
|
||||
switch sport.uppercased() {
|
||||
case "MLB": return .mlb
|
||||
case "NBA": return .nba
|
||||
case "NHL": return .nhl
|
||||
case "NFL": return .nfl
|
||||
case "MLS": return .mls
|
||||
default: return .mlb
|
||||
}
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
private func findStadiumId(venue: String, sport: Sport) -> UUID {
|
||||
let venueLower = venue.lowercased()
|
||||
|
||||
// Try exact match
|
||||
if let stadium = stadiumsByVenue[venueLower] {
|
||||
return stadium.id
|
||||
}
|
||||
|
||||
// Try partial match
|
||||
for (name, stadium) in stadiumsByVenue {
|
||||
if name.contains(venueLower) || venueLower.contains(name) {
|
||||
return stadium.id
|
||||
}
|
||||
}
|
||||
|
||||
// Generate deterministic ID for unknown venues
|
||||
return deterministicUUID(from: "venue_\(venue)")
|
||||
}
|
||||
|
||||
private func deterministicUUID(from string: String) -> UUID {
|
||||
// Create a deterministic UUID using SHA256 (truly deterministic across launches)
|
||||
let data = Data(string.utf8)
|
||||
let hash = SHA256.hash(data: data)
|
||||
let hashBytes = Array(hash)
|
||||
|
||||
// Use first 16 bytes of SHA256 hash
|
||||
var bytes = Array(hashBytes.prefix(16))
|
||||
|
||||
// Set UUID 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]
|
||||
))
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
private func extractCity(from fullName: String) -> String {
|
||||
// "Boston Celtics" -> "Boston"
|
||||
// "New York Knicks" -> "New York"
|
||||
// "Los Angeles Lakers" -> "Los Angeles"
|
||||
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))
|
||||
}
|
||||
|
||||
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] ?? ""
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user