feat: add ItineraryItemService with CloudKit sync
Debounced updates (1.5s), local-first with silent retry. Supports game, travel, and custom item kinds. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
198
SportsTime/Core/Services/ItineraryItemService.swift
Normal file
198
SportsTime/Core/Services/ItineraryItemService.swift
Normal file
@@ -0,0 +1,198 @@
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
/// Service for persisting and syncing ItineraryItems to CloudKit
|
||||
actor ItineraryItemService {
|
||||
static let shared = ItineraryItemService()
|
||||
|
||||
private let container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
||||
private var database: CKDatabase { container.privateCloudDatabase }
|
||||
|
||||
private let recordType = "ItineraryItem"
|
||||
|
||||
// Debounce tracking
|
||||
private var pendingUpdates: [UUID: ItineraryItem] = [:]
|
||||
private var retryCount: [UUID: Int] = [:]
|
||||
private var debounceTask: Task<Void, Never>?
|
||||
|
||||
private let maxRetries = 3
|
||||
|
||||
private init() {}
|
||||
|
||||
// MARK: - CRUD Operations
|
||||
|
||||
/// Fetch all items for a trip
|
||||
func fetchItems(forTripId tripId: UUID) async throws -> [ItineraryItem] {
|
||||
let predicate = NSPredicate(format: "tripId == %@", tripId.uuidString)
|
||||
let query = CKQuery(recordType: recordType, predicate: predicate)
|
||||
|
||||
let (results, _) = try await database.records(matching: query)
|
||||
|
||||
return results.compactMap { _, result in
|
||||
guard case .success(let record) = result else { return nil }
|
||||
return ItineraryItem(from: record)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new item
|
||||
func createItem(_ item: ItineraryItem) async throws -> ItineraryItem {
|
||||
let record = item.toCKRecord()
|
||||
let savedRecord = try await database.save(record)
|
||||
return ItineraryItem(from: savedRecord) ?? item
|
||||
}
|
||||
|
||||
/// Update an existing item (debounced)
|
||||
func updateItem(_ item: ItineraryItem) async {
|
||||
pendingUpdates[item.id] = item
|
||||
|
||||
// Cancel existing debounce
|
||||
debounceTask?.cancel()
|
||||
|
||||
// Start new debounce
|
||||
debounceTask = Task {
|
||||
try? await Task.sleep(for: .seconds(1.5))
|
||||
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
await flushPendingUpdates()
|
||||
}
|
||||
}
|
||||
|
||||
/// Force immediate sync of pending updates
|
||||
func flushPendingUpdates() async {
|
||||
let updates = pendingUpdates
|
||||
pendingUpdates.removeAll()
|
||||
|
||||
guard !updates.isEmpty else { return }
|
||||
|
||||
let records = updates.values.map { $0.toCKRecord() }
|
||||
|
||||
do {
|
||||
// Use .changedKeys save policy - CloudKit handles conflicts automatically
|
||||
_ = try await database.modifyRecords(saving: records, deleting: [], savePolicy: .changedKeys)
|
||||
|
||||
// Success - clear retry counts for saved items
|
||||
for item in updates.values {
|
||||
retryCount[item.id] = nil
|
||||
}
|
||||
} catch {
|
||||
// Add back to pending for retry, respecting max retry limit
|
||||
for item in updates.values {
|
||||
let currentRetries = retryCount[item.id] ?? 0
|
||||
if currentRetries < maxRetries {
|
||||
pendingUpdates[item.id] = item
|
||||
retryCount[item.id] = currentRetries + 1
|
||||
}
|
||||
// If max retries exceeded, silently drop the update to prevent infinite loop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete an item
|
||||
func deleteItem(_ itemId: UUID) async throws {
|
||||
let recordId = CKRecord.ID(recordName: itemId.uuidString)
|
||||
try await database.deleteRecord(withID: recordId)
|
||||
}
|
||||
|
||||
/// Delete all items for a trip
|
||||
func deleteItems(forTripId tripId: UUID) async throws {
|
||||
let items = try await fetchItems(forTripId: tripId)
|
||||
|
||||
for item in items {
|
||||
let recordId = CKRecord.ID(recordName: item.id.uuidString)
|
||||
try? await database.deleteRecord(withID: recordId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CloudKit Conversion
|
||||
|
||||
extension ItineraryItem {
|
||||
init?(from record: CKRecord) {
|
||||
guard let idString = record["itemId"] as? String,
|
||||
let id = UUID(uuidString: idString),
|
||||
let tripIdString = record["tripId"] as? String,
|
||||
let tripId = UUID(uuidString: tripIdString),
|
||||
let day = record["day"] as? Int,
|
||||
let sortOrder = record["sortOrder"] as? Double,
|
||||
let kindString = record["kind"] as? String,
|
||||
let modifiedAt = record["modifiedAt"] as? Date else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.id = id
|
||||
self.tripId = tripId
|
||||
self.day = day
|
||||
self.sortOrder = sortOrder
|
||||
self.modifiedAt = modifiedAt
|
||||
|
||||
// Parse kind
|
||||
switch kindString {
|
||||
case "game":
|
||||
guard let gameId = record["gameId"] as? String else { return nil }
|
||||
self.kind = .game(gameId: gameId)
|
||||
|
||||
case "travel":
|
||||
guard let fromCity = record["travelFromCity"] as? String,
|
||||
let toCity = record["travelToCity"] as? String else { return nil }
|
||||
let info = TravelInfo(
|
||||
fromCity: fromCity,
|
||||
toCity: toCity,
|
||||
distanceMeters: record["travelDistanceMeters"] as? Double,
|
||||
durationSeconds: record["travelDurationSeconds"] as? Double
|
||||
)
|
||||
self.kind = .travel(info)
|
||||
|
||||
case "custom":
|
||||
guard let title = record["customTitle"] as? String,
|
||||
let icon = record["customIcon"] as? String else { return nil }
|
||||
let info = CustomInfo(
|
||||
title: title,
|
||||
icon: icon,
|
||||
time: record["customTime"] as? Date,
|
||||
latitude: record["latitude"] as? Double,
|
||||
longitude: record["longitude"] as? Double,
|
||||
address: record["address"] as? String
|
||||
)
|
||||
self.kind = .custom(info)
|
||||
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func toCKRecord() -> CKRecord {
|
||||
let recordId = CKRecord.ID(recordName: id.uuidString)
|
||||
let record = CKRecord(recordType: "ItineraryItem", recordID: recordId)
|
||||
|
||||
record["itemId"] = id.uuidString
|
||||
record["tripId"] = tripId.uuidString
|
||||
record["day"] = day
|
||||
record["sortOrder"] = sortOrder
|
||||
record["modifiedAt"] = modifiedAt
|
||||
|
||||
switch kind {
|
||||
case .game(let gameId):
|
||||
record["kind"] = "game"
|
||||
record["gameId"] = gameId
|
||||
|
||||
case .travel(let info):
|
||||
record["kind"] = "travel"
|
||||
record["travelFromCity"] = info.fromCity
|
||||
record["travelToCity"] = info.toCity
|
||||
record["travelDistanceMeters"] = info.distanceMeters
|
||||
record["travelDurationSeconds"] = info.durationSeconds
|
||||
|
||||
case .custom(let info):
|
||||
record["kind"] = "custom"
|
||||
record["customTitle"] = info.title
|
||||
record["customIcon"] = info.icon
|
||||
record["customTime"] = info.time
|
||||
record["latitude"] = info.latitude
|
||||
record["longitude"] = info.longitude
|
||||
record["address"] = info.address
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user