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:
Trey t
2026-01-16 00:31:44 -06:00
parent b534ca771b
commit 495ef88303
13 changed files with 1302 additions and 33 deletions

View File

@@ -0,0 +1,48 @@
//
// AppDelegate.swift
// SportsTime
//
// Handles push notification registration and CloudKit subscription notifications
//
import UIKit
import CloudKit
import UserNotifications
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil
) -> Bool {
// Register for remote notifications (required for CloudKit subscriptions)
application.registerForRemoteNotifications()
return true
}
func application(
_ application: UIApplication,
didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data
) {
print("📡 [Push] Registered for remote notifications")
}
func application(
_ application: UIApplication,
didFailToRegisterForRemoteNotificationsWithError error: Error
) {
print("📡 [Push] Failed to register: \(error.localizedDescription)")
}
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable: Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
// Handle CloudKit subscription notification
Task {
await CustomItemSubscriptionService.shared.handleRemoteNotification(userInfo: userInfo)
completionHandler(.newData)
}
}
}

View File

@@ -0,0 +1,204 @@
//
// 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
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)"
}
}
}

View 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()
}
}