Files
Sportstime/SportsTime/Core/Services/CustomItemService.swift
Trey t 8df33a5614 WIP: map route updates and custom item drag/drop fixes (broken)
- Fixed map header not updating in ItineraryTableViewWrapper using Coordinator pattern
- Added routeVersion UUID to force Map re-render when routes change
- Fixed determineAnchor to scan backwards for correct anchor context
- Added location support to CustomItineraryItem (lat/lng/address)
- Added MapKit place search to AddItemSheet
- Added extensive debug logging for route waypoint calculation

Known issues:
- Custom items still not routing correctly after drag/drop
- Anchor type determination may still have bugs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 00:00:57 -06:00

209 lines
7.7 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
// 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<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)"
}
}
}