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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user