// // CustomItemService.swift // SportsTime // // CloudKit service for custom itinerary items // import Foundation import CloudKit actor CustomItemService { static let shared = CustomItemService() private let container: CKContainer private let publicDatabase: CKDatabase private init() { self.container = CKContainer(identifier: "iCloud.com.sportstime.app") self.publicDatabase = container.publicCloudDatabase } // MARK: - CRUD Operations func createItem(_ item: CustomItineraryItem) async throws -> CustomItineraryItem { print("☁️ [CloudKit] Creating record for item: \(item.id)") let ckItem = CKCustomItineraryItem(item: item) do { let savedRecord = try await publicDatabase.save(ckItem.record) print("☁️ [CloudKit] Record saved: \(savedRecord.recordID.recordName)") return item } catch let error as CKError { print("☁️ [CloudKit] CKError on create: \(error.code.rawValue) - \(error.localizedDescription)") throw mapCloudKitError(error) } catch { print("☁️ [CloudKit] Unknown error on create: \(error)") throw CustomItemError.unknown(error) } } func updateItem(_ item: CustomItineraryItem) async throws -> CustomItineraryItem { // Fetch existing record to get changeTag let recordID = CKRecord.ID(recordName: item.id.uuidString) let existingRecord: CKRecord do { existingRecord = try await publicDatabase.record(for: recordID) } catch let error as CKError { throw mapCloudKitError(error) } catch { throw CustomItemError.unknown(error) } // Update fields let now = Date() existingRecord[CKCustomItineraryItem.categoryKey] = item.category.rawValue existingRecord[CKCustomItineraryItem.titleKey] = item.title existingRecord[CKCustomItineraryItem.anchorTypeKey] = item.anchorType.rawValue existingRecord[CKCustomItineraryItem.anchorIdKey] = item.anchorId existingRecord[CKCustomItineraryItem.anchorDayKey] = item.anchorDay existingRecord[CKCustomItineraryItem.sortOrderKey] = item.sortOrder existingRecord[CKCustomItineraryItem.modifiedAtKey] = now // Location fields (nil values clear the field in CloudKit) existingRecord[CKCustomItineraryItem.latitudeKey] = item.latitude existingRecord[CKCustomItineraryItem.longitudeKey] = item.longitude existingRecord[CKCustomItineraryItem.addressKey] = item.address do { try await publicDatabase.save(existingRecord) var updatedItem = item updatedItem.modifiedAt = now return updatedItem } catch let error as CKError { throw mapCloudKitError(error) } catch { throw CustomItemError.unknown(error) } } /// Batch update sortOrder for multiple items (for reordering) func updateSortOrders(_ items: [CustomItineraryItem]) async throws { guard !items.isEmpty else { return } print("☁️ [CloudKit] Batch updating sortOrder for \(items.count) items") // Fetch all records let recordIDs = items.map { CKRecord.ID(recordName: $0.id.uuidString) } let fetchResults = try await publicDatabase.records(for: recordIDs) // Update each record's sortOrder var recordsToSave: [CKRecord] = [] let now = Date() for item in items { let recordID = CKRecord.ID(recordName: item.id.uuidString) guard case .success(let record) = fetchResults[recordID] else { continue } record[CKCustomItineraryItem.sortOrderKey] = item.sortOrder record[CKCustomItineraryItem.modifiedAtKey] = now recordsToSave.append(record) } // Save all in one batch operation let modifyOp = CKModifyRecordsOperation(recordsToSave: recordsToSave, recordIDsToDelete: nil) modifyOp.savePolicy = .changedKeys try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in modifyOp.modifyRecordsResultBlock = { result in switch result { case .success: print("☁️ [CloudKit] Batch sortOrder update complete") continuation.resume() case .failure(let error): print("☁️ [CloudKit] Batch update failed: \(error)") continuation.resume(throwing: error) } } publicDatabase.add(modifyOp) } } func deleteItem(_ itemId: UUID) async throws { let recordID = CKRecord.ID(recordName: itemId.uuidString) do { try await publicDatabase.deleteRecord(withID: recordID) } catch let error as CKError { if error.code != .unknownItem { throw mapCloudKitError(error) } // Item already deleted - ignore } catch { throw CustomItemError.unknown(error) } } func fetchItems(forTripId tripId: UUID) async throws -> [CustomItineraryItem] { print("☁️ [CloudKit] Fetching items for tripId: \(tripId.uuidString)") let predicate = NSPredicate( format: "%K == %@", CKCustomItineraryItem.tripIdKey, tripId.uuidString ) let query = CKQuery(recordType: CKRecordType.customItineraryItem, predicate: predicate) do { let (results, _) = try await publicDatabase.records(matching: query) print("☁️ [CloudKit] Query returned \(results.count) records") let items = results.compactMap { result -> CustomItineraryItem? in guard case .success(let record) = result.1 else { print("☁️ [CloudKit] Record fetch failed for: \(result.0.recordName)") return nil } let item = CKCustomItineraryItem(record: record).toItem() if item == nil { print("☁️ [CloudKit] Failed to parse record: \(record.recordID.recordName)") } return item }.sorted { ($0.anchorDay, $0.sortOrder) < ($1.anchorDay, $1.sortOrder) } print("☁️ [CloudKit] Parsed \(items.count) valid items") return items } catch let error as CKError { print("☁️ [CloudKit] CKError on fetch: \(error.code.rawValue) - \(error.localizedDescription)") throw mapCloudKitError(error) } catch { print("☁️ [CloudKit] Unknown error on fetch: \(error)") throw CustomItemError.unknown(error) } } // MARK: - Error Mapping private func mapCloudKitError(_ error: CKError) -> CustomItemError { switch error.code { case .notAuthenticated: return .notSignedIn case .networkUnavailable, .networkFailure: return .networkUnavailable case .unknownItem: return .itemNotFound default: return .unknown(error) } } } // MARK: - Errors enum CustomItemError: Error, LocalizedError { case notSignedIn case itemNotFound case networkUnavailable case unknown(Error) var errorDescription: String? { switch self { case .notSignedIn: return "Please sign in to iCloud to use custom items." case .itemNotFound: return "Item not found. It may have been deleted." case .networkUnavailable: return "Unable to connect. Please check your internet connection." case .unknown(let error): return "An error occurred: \(error.localizedDescription)" } } }