- 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>
148 lines
5.3 KiB
Swift
148 lines
5.3 KiB
Swift
//
|
|
// CustomItemSubscriptionService.swift
|
|
// SportsTime
|
|
//
|
|
// CloudKit subscription service for real-time custom item updates
|
|
//
|
|
|
|
import Foundation
|
|
import CloudKit
|
|
import Combine
|
|
|
|
/// Manages CloudKit subscriptions for custom itinerary items on shared trips
|
|
actor CustomItemSubscriptionService {
|
|
static let shared = CustomItemSubscriptionService()
|
|
|
|
private let container: CKContainer
|
|
private let publicDatabase: CKDatabase
|
|
|
|
/// Publisher that emits trip IDs when their custom items change
|
|
private let changeSubject = PassthroughSubject<UUID, Never>()
|
|
var changePublisher: AnyPublisher<UUID, Never> {
|
|
changeSubject.eraseToAnyPublisher()
|
|
}
|
|
|
|
/// Track which trip IDs have active subscriptions
|
|
private var subscribedTripIds: Set<UUID> = []
|
|
|
|
private init() {
|
|
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
|
self.publicDatabase = container.publicCloudDatabase
|
|
}
|
|
|
|
// MARK: - Subscription Management
|
|
|
|
/// Subscribe to changes for a specific trip's custom items
|
|
func subscribeToTrip(_ tripId: UUID) async throws {
|
|
guard !subscribedTripIds.contains(tripId) else {
|
|
print("📡 [Subscription] Already subscribed to trip: \(tripId)")
|
|
return
|
|
}
|
|
|
|
let subscriptionID = "custom-items-\(tripId.uuidString)"
|
|
|
|
// Check if subscription already exists
|
|
do {
|
|
_ = try await publicDatabase.subscription(for: subscriptionID)
|
|
subscribedTripIds.insert(tripId)
|
|
print("📡 [Subscription] Found existing subscription for trip: \(tripId)")
|
|
return
|
|
} catch let error as CKError where error.code == .unknownItem {
|
|
// Subscription doesn't exist, create it
|
|
} catch {
|
|
print("📡 [Subscription] Error checking subscription: \(error)")
|
|
}
|
|
|
|
// Create subscription for this trip's custom items
|
|
let predicate = NSPredicate(
|
|
format: "%K == %@",
|
|
CKCustomItineraryItem.tripIdKey,
|
|
tripId.uuidString
|
|
)
|
|
|
|
let subscription = CKQuerySubscription(
|
|
recordType: CKRecordType.customItineraryItem,
|
|
predicate: predicate,
|
|
subscriptionID: subscriptionID,
|
|
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
|
|
)
|
|
|
|
// Configure notification
|
|
let notificationInfo = CKSubscription.NotificationInfo()
|
|
notificationInfo.shouldSendContentAvailable = true // Silent notification
|
|
subscription.notificationInfo = notificationInfo
|
|
|
|
do {
|
|
try await publicDatabase.save(subscription)
|
|
subscribedTripIds.insert(tripId)
|
|
print("📡 [Subscription] Created subscription for trip: \(tripId)")
|
|
} catch let error as CKError {
|
|
print("📡 [Subscription] Failed to create subscription: \(error.code.rawValue) - \(error.localizedDescription)")
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/// Unsubscribe from a specific trip's custom items
|
|
func unsubscribeFromTrip(_ tripId: UUID) async throws {
|
|
guard subscribedTripIds.contains(tripId) else { return }
|
|
|
|
let subscriptionID = "custom-items-\(tripId.uuidString)"
|
|
|
|
do {
|
|
try await publicDatabase.deleteSubscription(withID: subscriptionID)
|
|
subscribedTripIds.remove(tripId)
|
|
print("📡 [Subscription] Removed subscription for trip: \(tripId)")
|
|
} catch let error as CKError where error.code == .unknownItem {
|
|
// Already deleted
|
|
subscribedTripIds.remove(tripId)
|
|
}
|
|
}
|
|
|
|
/// Subscribe to all trips that the user has access to (via polls)
|
|
func subscribeToSharedTrips(tripIds: [UUID]) async {
|
|
for tripId in tripIds {
|
|
do {
|
|
try await subscribeToTrip(tripId)
|
|
} catch {
|
|
print("📡 [Subscription] Failed to subscribe to trip \(tripId): \(error)")
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Notification Handling
|
|
|
|
/// Handle CloudKit notification - call this from AppDelegate/SceneDelegate
|
|
func handleNotification(_ notification: CKNotification) {
|
|
guard let queryNotification = notification as? CKQueryNotification,
|
|
let subscriptionID = queryNotification.subscriptionID,
|
|
subscriptionID.hasPrefix("custom-items-") else {
|
|
return
|
|
}
|
|
|
|
// Extract trip ID from subscription ID
|
|
let tripIdString = String(subscriptionID.dropFirst("custom-items-".count))
|
|
guard let tripId = UUID(uuidString: tripIdString) else { return }
|
|
|
|
print("📡 [Subscription] Received change notification for trip: \(tripId)")
|
|
changeSubject.send(tripId)
|
|
}
|
|
|
|
/// Handle notification from userInfo dictionary (from push notification)
|
|
nonisolated func handleRemoteNotification(userInfo: [AnyHashable: Any]) async {
|
|
guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo) else {
|
|
return
|
|
}
|
|
await handleNotification(notification)
|
|
}
|
|
|
|
// MARK: - Cleanup
|
|
|
|
/// Remove all subscriptions (call on sign out or cleanup)
|
|
func removeAllSubscriptions() async {
|
|
for tripId in subscribedTripIds {
|
|
try? await unsubscribeFromTrip(tripId)
|
|
}
|
|
subscribedTripIds.removeAll()
|
|
}
|
|
}
|