- Add CKSport model to parse CloudKit Sport records - Add fetchSportsForSync() to CloudKitService for delta fetching - Add syncSports() and mergeSport() to CanonicalSyncService - Update DataProvider with dynamicSports support and allSports computed property - Update MockAppDataProvider with matching dynamic sports support - Add comprehensive documentation for adding new sports The app can now sync sport definitions from CloudKit, enabling new sports to be added without app updates. Sports are fetched, merged into SwiftData, and exposed via AppDataProvider.allSports alongside built-in Sport enum cases. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
522 lines
19 KiB
Swift
522 lines
19 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: - Sync Types (include canonical IDs from CloudKit)
|
|
|
|
struct SyncStadium {
|
|
let stadium: Stadium
|
|
let canonicalId: String
|
|
}
|
|
|
|
struct SyncTeam {
|
|
let team: Team
|
|
let canonicalId: String
|
|
let stadiumCanonicalId: String
|
|
}
|
|
|
|
struct SyncGame {
|
|
let game: Game
|
|
let canonicalId: String
|
|
let homeTeamCanonicalId: String
|
|
let awayTeamCanonicalId: String
|
|
let stadiumCanonicalId: String
|
|
}
|
|
|
|
// 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
|
|
else { return nil }
|
|
|
|
let homeId = homeRef.recordID.recordName
|
|
let awayId = awayRef.recordID.recordName
|
|
|
|
// Stadium ref is optional - use placeholder if not present
|
|
let stadiumId: String
|
|
if let stadiumRef = record[CKGame.stadiumRefKey] as? CKRecord.Reference {
|
|
stadiumId = stadiumRef.recordID.recordName
|
|
} else {
|
|
stadiumId = "stadium_placeholder_\(UUID().uuidString)" // Placeholder - will be resolved via team lookup
|
|
}
|
|
|
|
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
|
|
}
|
|
|
|
allGames.append(contentsOf: games)
|
|
}
|
|
|
|
return allGames.sorted { $0.dateTime < $1.dateTime }
|
|
}
|
|
|
|
func fetchGame(by id: String) async throws -> Game? {
|
|
let predicate = NSPredicate(format: "gameId == %@", id)
|
|
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
|
|
else { return nil }
|
|
|
|
let homeId = homeRef.recordID.recordName
|
|
let awayId = awayRef.recordID.recordName
|
|
let stadiumId = stadiumRef.recordID.recordName
|
|
|
|
return ckGame.game(homeTeamId: homeId, awayTeamId: awayId, stadiumId: stadiumId)
|
|
}
|
|
|
|
// MARK: - Sync Fetch Methods (return canonical IDs directly from CloudKit)
|
|
|
|
/// Fetch stadiums for sync operations
|
|
/// - Parameter lastSync: If nil, fetches all stadiums. If provided, fetches only stadiums modified since that date.
|
|
func fetchStadiumsForSync(since lastSync: Date?) async throws -> [SyncStadium] {
|
|
let predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
let query = CKQuery(recordType: CKRecordType.stadium, predicate: predicate)
|
|
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result -> SyncStadium? in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
let ckStadium = CKStadium(record: record)
|
|
guard let stadium = ckStadium.stadium,
|
|
let canonicalId = ckStadium.canonicalId
|
|
else { return nil }
|
|
return SyncStadium(stadium: stadium, canonicalId: canonicalId)
|
|
}
|
|
}
|
|
|
|
/// Fetch teams for sync operations
|
|
/// - Parameter lastSync: If nil, fetches all teams. If provided, fetches only teams modified since that date.
|
|
func fetchTeamsForSync(since lastSync: Date?) async throws -> [SyncTeam] {
|
|
let predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
let query = CKQuery(recordType: CKRecordType.team, predicate: predicate)
|
|
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result -> SyncTeam? in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
let ckTeam = CKTeam(record: record)
|
|
guard let team = ckTeam.team,
|
|
let canonicalId = ckTeam.canonicalId,
|
|
let stadiumCanonicalId = ckTeam.stadiumCanonicalId
|
|
else { return nil }
|
|
return SyncTeam(team: team, canonicalId: canonicalId, stadiumCanonicalId: stadiumCanonicalId)
|
|
}
|
|
}
|
|
|
|
/// Fetch games for sync operations
|
|
/// - Parameter lastSync: If nil, fetches all games. If provided, fetches only games modified since that date.
|
|
func fetchGamesForSync(since lastSync: Date?) async throws -> [SyncGame] {
|
|
let predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
let query = CKQuery(recordType: CKRecordType.game, predicate: predicate)
|
|
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result -> SyncGame? in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
let ckGame = CKGame(record: record)
|
|
|
|
guard let canonicalId = ckGame.canonicalId,
|
|
let homeTeamCanonicalId = ckGame.homeTeamCanonicalId,
|
|
let awayTeamCanonicalId = ckGame.awayTeamCanonicalId,
|
|
let stadiumCanonicalId = ckGame.stadiumCanonicalId
|
|
else { return nil }
|
|
|
|
guard let game = ckGame.game(
|
|
homeTeamId: homeTeamCanonicalId,
|
|
awayTeamId: awayTeamCanonicalId,
|
|
stadiumId: stadiumCanonicalId
|
|
) else { return nil }
|
|
|
|
return SyncGame(
|
|
game: game,
|
|
canonicalId: canonicalId,
|
|
homeTeamCanonicalId: homeTeamCanonicalId,
|
|
awayTeamCanonicalId: awayTeamCanonicalId,
|
|
stadiumCanonicalId: stadiumCanonicalId
|
|
)
|
|
}.sorted { $0.game.dateTime < $1.game.dateTime }
|
|
}
|
|
|
|
// MARK: - League Structure & Team Aliases
|
|
|
|
func fetchLeagueStructure(for sport: Sport? = nil) async throws -> [LeagueStructureModel] {
|
|
let predicate: NSPredicate
|
|
if let sport = sport {
|
|
predicate = NSPredicate(format: "sport == %@", sport.rawValue)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
|
|
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
|
|
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.displayOrderKey, ascending: true)]
|
|
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
return CKLeagueStructure(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
func fetchTeamAliases(for teamCanonicalId: String? = nil) async throws -> [TeamAlias] {
|
|
let predicate: NSPredicate
|
|
if let teamId = teamCanonicalId {
|
|
predicate = NSPredicate(format: "teamCanonicalId == %@", teamId)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
|
|
let query = CKQuery(recordType: CKRecordType.teamAlias, 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 CKTeamAlias(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
func fetchStadiumAliases(for stadiumCanonicalId: String? = nil) async throws -> [StadiumAlias] {
|
|
let predicate: NSPredicate
|
|
if let stadiumId = stadiumCanonicalId {
|
|
predicate = NSPredicate(format: "stadiumCanonicalId == %@", stadiumId)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
|
|
let query = CKQuery(recordType: CKRecordType.stadiumAlias, 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 CKStadiumAlias(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
// MARK: - Delta Sync (Date-Based for Public Database)
|
|
|
|
/// Fetch league structure records modified after the given date
|
|
func fetchLeagueStructureChanges(since lastSync: Date?) async throws -> [LeagueStructureModel] {
|
|
let predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
|
|
let query = CKQuery(recordType: CKRecordType.leagueStructure, predicate: predicate)
|
|
query.sortDescriptors = [NSSortDescriptor(key: CKLeagueStructure.lastModifiedKey, ascending: true)]
|
|
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
return CKLeagueStructure(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
/// Fetch team alias records modified after the given date
|
|
func fetchTeamAliasChanges(since lastSync: Date?) async throws -> [TeamAlias] {
|
|
let predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
|
|
let query = CKQuery(recordType: CKRecordType.teamAlias, predicate: predicate)
|
|
query.sortDescriptors = [NSSortDescriptor(key: CKTeamAlias.lastModifiedKey, ascending: true)]
|
|
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
return CKTeamAlias(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
/// Fetch stadium alias records modified after the given date
|
|
func fetchStadiumAliasChanges(since lastSync: Date?) async throws -> [StadiumAlias] {
|
|
let predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "lastModified > %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
|
|
let query = CKQuery(recordType: CKRecordType.stadiumAlias, predicate: predicate)
|
|
query.sortDescriptors = [NSSortDescriptor(key: CKStadiumAlias.lastModifiedKey, ascending: true)]
|
|
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
return CKStadiumAlias(record: record).toModel()
|
|
}
|
|
}
|
|
|
|
// MARK: - Sport Sync
|
|
|
|
/// Fetch sports for sync operations
|
|
/// - Parameter lastSync: If nil, fetches all sports. If provided, fetches only sports modified since that date.
|
|
func fetchSportsForSync(since lastSync: Date?) async throws -> [CanonicalSport] {
|
|
let predicate: NSPredicate
|
|
if let lastSync = lastSync {
|
|
predicate = NSPredicate(format: "modificationDate >= %@", lastSync as NSDate)
|
|
} else {
|
|
predicate = NSPredicate(value: true)
|
|
}
|
|
let query = CKQuery(recordType: CKRecordType.sport, predicate: predicate)
|
|
|
|
let (results, _) = try await publicDatabase.records(matching: query)
|
|
|
|
return results.compactMap { result -> CanonicalSport? in
|
|
guard case .success(let record) = result.1 else { return nil }
|
|
return CKSport(record: record).toCanonical()
|
|
}
|
|
}
|
|
|
|
// MARK: - Sync Status
|
|
|
|
func checkAccountStatus() async -> CKAccountStatus {
|
|
do {
|
|
return try await container.accountStatus()
|
|
} catch {
|
|
return .couldNotDetermine
|
|
}
|
|
}
|
|
|
|
// MARK: - Subscriptions
|
|
|
|
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)
|
|
}
|
|
|
|
func subscribeToLeagueStructureUpdates() async throws {
|
|
let subscription = CKQuerySubscription(
|
|
recordType: CKRecordType.leagueStructure,
|
|
predicate: NSPredicate(value: true),
|
|
subscriptionID: "league-structure-updates",
|
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
|
)
|
|
|
|
let notification = CKSubscription.NotificationInfo()
|
|
notification.shouldSendContentAvailable = true
|
|
subscription.notificationInfo = notification
|
|
|
|
try await publicDatabase.save(subscription)
|
|
}
|
|
|
|
func subscribeToTeamAliasUpdates() async throws {
|
|
let subscription = CKQuerySubscription(
|
|
recordType: CKRecordType.teamAlias,
|
|
predicate: NSPredicate(value: true),
|
|
subscriptionID: "team-alias-updates",
|
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
|
)
|
|
|
|
let notification = CKSubscription.NotificationInfo()
|
|
notification.shouldSendContentAvailable = true
|
|
subscription.notificationInfo = notification
|
|
|
|
try await publicDatabase.save(subscription)
|
|
}
|
|
|
|
func subscribeToStadiumAliasUpdates() async throws {
|
|
let subscription = CKQuerySubscription(
|
|
recordType: CKRecordType.stadiumAlias,
|
|
predicate: NSPredicate(value: true),
|
|
subscriptionID: "stadium-alias-updates",
|
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate]
|
|
)
|
|
|
|
let notification = CKSubscription.NotificationInfo()
|
|
notification.shouldSendContentAvailable = true
|
|
subscription.notificationInfo = notification
|
|
|
|
try await publicDatabase.save(subscription)
|
|
}
|
|
|
|
/// Subscribe to all canonical data updates
|
|
func subscribeToAllUpdates() async throws {
|
|
try await subscribeToScheduleUpdates()
|
|
try await subscribeToLeagueStructureUpdates()
|
|
try await subscribeToTeamAliasUpdates()
|
|
try await subscribeToStadiumAliasUpdates()
|
|
}
|
|
}
|