- Add CustomItineraryItem domain model with sortOrder for ordering - Add CKCustomItineraryItem CloudKit wrapper for persistence - Create CustomItemService for CRUD operations - Create CustomItemSubscriptionService for real-time sync - Add AppDelegate for push notification handling - Add AddItemSheet for creating/editing items - Add CustomItemRow with drag handle - Update TripDetailView with continuous vertical timeline - Enable drag-to-reorder using .draggable/.dropDestination - Add inline "Add" buttons after games and travel segments Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
205 lines
7.4 KiB
Swift
205 lines
7.4 KiB
Swift
//
|
|
// 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
|
|
|
|
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<Void, Error>) 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)"
|
|
}
|
|
}
|
|
}
|