Files
Sportstime/SportsTime/Core/Services/CloudKitService.swift
Trey t 9088b46563 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>
2026-01-07 00:46:40 -06:00

212 lines
7.2 KiB
Swift

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