feat(itinerary): add custom itinerary items with drag-to-reorder
- 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>
This commit is contained in:
147
SportsTime/Core/Services/CustomItemSubscriptionService.swift
Normal file
147
SportsTime/Core/Services/CustomItemSubscriptionService.swift
Normal file
@@ -0,0 +1,147 @@
|
||||
//
|
||||
// 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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user