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:
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