Critical:
- ProgressViewModel: use single stored ModelContext instead of creating
new ones per operation (deleteVisit silently no-op'd)
- ProgressViewModel: convert expensive computed properties to stored
with explicit recompute after mutations (3x recomputation per render)
Memory:
- AnimatedSportsIcon: replace recursive GCD asyncAfter with Task loop,
cancelled in onDisappear (19 unkillable timer chains)
- ItineraryItemService: remove [weak self] from actor Task (semantically
wrong, silently drops flushPendingUpdates)
- VisitPhotoService: remove [weak self] from @MainActor Task closures
Concurrency:
- StoreManager: replace nested MainActor.run{Task{}} with direct await
in listenForTransactions (fire-and-forget race)
- VisitPhotoService: move JPEG encoding/file writing off MainActor via
nonisolated static helper + Task.detached
- SportsIconImageGenerator: replace GCD dispatch with Task.detached for
structured concurrency compliance
Performance:
- Game/RichGame: cache DateFormatters as static lets instead of
allocating per-call (hundreds of allocations in schedule view)
- TripDetailView: wrap ~10 routeWaypoints print() in #if DEBUG, remove
2 let _ = print() from TripMapView.body (fires every render)
Accessibility:
- GameRow: add combined VoiceOver label (was reading abbreviations
letter-by-letter)
- Sport badges: add accessibilityLabel to prevent SF symbol name readout
- SportsTimeApp: post UIAccessibility.screenChanged after bootstrap
completes so VoiceOver users know app is ready
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
219 lines
7.4 KiB
Swift
219 lines
7.4 KiB
Swift
import Foundation
|
|
import CloudKit
|
|
|
|
/// Service for persisting and syncing ItineraryItems to CloudKit
|
|
actor ItineraryItemService {
|
|
static let shared = ItineraryItemService()
|
|
|
|
private let container = CloudKitContainerConfig.makeContainer()
|
|
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 let debounceInterval: Duration = .seconds(1.5)
|
|
|
|
private init() {}
|
|
|
|
/// Cancel any pending debounce task (for cleanup)
|
|
func cancelPendingSync() {
|
|
debounceTask?.cancel()
|
|
debounceTask = nil
|
|
}
|
|
|
|
// 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 task
|
|
debounceTask?.cancel()
|
|
|
|
// Start new debounce task with proper cancellation handling
|
|
debounceTask = Task {
|
|
// Check cancellation before sleeping
|
|
guard !Task.isCancelled else { return }
|
|
|
|
do {
|
|
try await Task.sleep(for: debounceInterval)
|
|
} catch {
|
|
// Task was cancelled during sleep
|
|
return
|
|
}
|
|
|
|
// Check cancellation after sleeping (belt and suspenders)
|
|
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,
|
|
let gameCity = record["gameCity"] as? String else { return nil }
|
|
self.kind = .game(gameId: gameId, city: gameCity)
|
|
|
|
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,
|
|
segmentIndex: record["travelSegmentIndex"] as? Int,
|
|
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, let city):
|
|
record["kind"] = "game"
|
|
record["gameId"] = gameId
|
|
record["gameCity"] = city
|
|
|
|
case .travel(let info):
|
|
record["kind"] = "travel"
|
|
record["travelFromCity"] = info.fromCity
|
|
record["travelToCity"] = info.toCity
|
|
record["travelSegmentIndex"] = info.segmentIndex
|
|
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
|
|
}
|
|
}
|