diff --git a/SportsTime/Core/Models/CloudKit/CKModels.swift b/SportsTime/Core/Models/CloudKit/CKModels.swift index 6c74928..1360300 100644 --- a/SportsTime/Core/Models/CloudKit/CKModels.swift +++ b/SportsTime/Core/Models/CloudKit/CKModels.swift @@ -20,6 +20,7 @@ enum CKRecordType { static let stadiumAlias = "StadiumAlias" static let tripPoll = "TripPoll" static let pollVote = "PollVote" + static let customItineraryItem = "CustomItineraryItem" } // MARK: - CKTeam @@ -622,3 +623,74 @@ struct CKPollVote { ) } } + +// MARK: - CKCustomItineraryItem + +struct CKCustomItineraryItem { + static let itemIdKey = "itemId" + static let tripIdKey = "tripId" + static let categoryKey = "category" + static let titleKey = "title" + static let anchorTypeKey = "anchorType" + static let anchorIdKey = "anchorId" + static let anchorDayKey = "anchorDay" + static let sortOrderKey = "sortOrder" + static let createdAtKey = "createdAt" + static let modifiedAtKey = "modifiedAt" + + let record: CKRecord + + init(record: CKRecord) { + self.record = record + } + + init(item: CustomItineraryItem) { + let record = CKRecord( + recordType: CKRecordType.customItineraryItem, + recordID: CKRecord.ID(recordName: item.id.uuidString) + ) + record[CKCustomItineraryItem.itemIdKey] = item.id.uuidString + record[CKCustomItineraryItem.tripIdKey] = item.tripId.uuidString + record[CKCustomItineraryItem.categoryKey] = item.category.rawValue + record[CKCustomItineraryItem.titleKey] = item.title + record[CKCustomItineraryItem.anchorTypeKey] = item.anchorType.rawValue + record[CKCustomItineraryItem.anchorIdKey] = item.anchorId + record[CKCustomItineraryItem.anchorDayKey] = item.anchorDay + record[CKCustomItineraryItem.sortOrderKey] = item.sortOrder + record[CKCustomItineraryItem.createdAtKey] = item.createdAt + record[CKCustomItineraryItem.modifiedAtKey] = item.modifiedAt + self.record = record + } + + func toItem() -> CustomItineraryItem? { + guard let itemIdString = record[CKCustomItineraryItem.itemIdKey] as? String, + let itemId = UUID(uuidString: itemIdString), + let tripIdString = record[CKCustomItineraryItem.tripIdKey] as? String, + let tripId = UUID(uuidString: tripIdString), + let categoryString = record[CKCustomItineraryItem.categoryKey] as? String, + let category = CustomItineraryItem.ItemCategory(rawValue: categoryString), + let title = record[CKCustomItineraryItem.titleKey] as? String, + let anchorTypeString = record[CKCustomItineraryItem.anchorTypeKey] as? String, + let anchorType = CustomItineraryItem.AnchorType(rawValue: anchorTypeString), + let anchorDay = record[CKCustomItineraryItem.anchorDayKey] as? Int, + let createdAt = record[CKCustomItineraryItem.createdAtKey] as? Date, + let modifiedAt = record[CKCustomItineraryItem.modifiedAtKey] as? Date + else { return nil } + + let anchorId = record[CKCustomItineraryItem.anchorIdKey] as? String + let sortOrder = record[CKCustomItineraryItem.sortOrderKey] as? Int ?? 0 + + return CustomItineraryItem( + id: itemId, + tripId: tripId, + category: category, + title: title, + anchorType: anchorType, + anchorId: anchorId, + anchorDay: anchorDay, + sortOrder: sortOrder, + createdAt: createdAt, + modifiedAt: modifiedAt + ) + } +} diff --git a/SportsTime/Core/Models/Domain/CustomItineraryItem.swift b/SportsTime/Core/Models/Domain/CustomItineraryItem.swift new file mode 100644 index 0000000..e56e339 --- /dev/null +++ b/SportsTime/Core/Models/Domain/CustomItineraryItem.swift @@ -0,0 +1,83 @@ +// +// CustomItineraryItem.swift +// SportsTime +// + +import Foundation + +struct CustomItineraryItem: Identifiable, Codable, Hashable { + let id: UUID + let tripId: UUID + var category: ItemCategory + var title: String + var anchorType: AnchorType + var anchorId: String? + var anchorDay: Int + var sortOrder: Int // For ordering within same anchor position + let createdAt: Date + var modifiedAt: Date + + init( + id: UUID = UUID(), + tripId: UUID, + category: ItemCategory, + title: String, + anchorType: AnchorType = .startOfDay, + anchorId: String? = nil, + anchorDay: Int, + sortOrder: Int = 0, + createdAt: Date = Date(), + modifiedAt: Date = Date() + ) { + self.id = id + self.tripId = tripId + self.category = category + self.title = title + self.anchorType = anchorType + self.anchorId = anchorId + self.anchorDay = anchorDay + self.sortOrder = sortOrder + self.createdAt = createdAt + self.modifiedAt = modifiedAt + } + + enum ItemCategory: String, Codable, CaseIterable { + case restaurant + case hotel + case activity + case note + + var icon: String { + switch self { + case .restaurant: return "🍽️" + case .hotel: return "🏨" + case .activity: return "🎯" + case .note: return "📝" + } + } + + var label: String { + switch self { + case .restaurant: return "Restaurant" + case .hotel: return "Hotel" + case .activity: return "Activity" + case .note: return "Note" + } + } + + var systemImage: String { + switch self { + case .restaurant: return "fork.knife" + case .hotel: return "bed.double.fill" + case .activity: return "figure.run" + case .note: return "note.text" + } + } + } + + enum AnchorType: String, Codable { + case startOfDay + case afterGame + case afterTravel + } +} diff --git a/SportsTime/Core/Models/Local/SavedTrip.swift b/SportsTime/Core/Models/Local/SavedTrip.swift index a915c67..db2201e 100644 --- a/SportsTime/Core/Models/Local/SavedTrip.swift +++ b/SportsTime/Core/Models/Local/SavedTrip.swift @@ -101,6 +101,79 @@ final class TripVote { } } +// MARK: - Local Custom Item (Cache) + +@Model +final class LocalCustomItem { + @Attribute(.unique) var id: UUID + var tripId: UUID + var category: String + var title: String + var anchorType: String + var anchorId: String? + var anchorDay: Int + var createdAt: Date + var modifiedAt: Date + var pendingSync: Bool // True if needs to sync to CloudKit + + init( + id: UUID = UUID(), + tripId: UUID, + category: CustomItineraryItem.ItemCategory, + title: String, + anchorType: CustomItineraryItem.AnchorType = .startOfDay, + anchorId: String? = nil, + anchorDay: Int, + createdAt: Date = Date(), + modifiedAt: Date = Date(), + pendingSync: Bool = false + ) { + self.id = id + self.tripId = tripId + self.category = category.rawValue + self.title = title + self.anchorType = anchorType.rawValue + self.anchorId = anchorId + self.anchorDay = anchorDay + self.createdAt = createdAt + self.modifiedAt = modifiedAt + self.pendingSync = pendingSync + } + + var toItem: CustomItineraryItem? { + guard let category = CustomItineraryItem.ItemCategory(rawValue: category), + let anchorType = CustomItineraryItem.AnchorType(rawValue: anchorType) + else { return nil } + + return CustomItineraryItem( + id: id, + tripId: tripId, + category: category, + title: title, + anchorType: anchorType, + anchorId: anchorId, + anchorDay: anchorDay, + createdAt: createdAt, + modifiedAt: modifiedAt + ) + } + + static func from(_ item: CustomItineraryItem, pendingSync: Bool = false) -> LocalCustomItem { + LocalCustomItem( + id: item.id, + tripId: item.tripId, + category: item.category, + title: item.title, + anchorType: item.anchorType, + anchorId: item.anchorId, + anchorDay: item.anchorDay, + createdAt: item.createdAt, + modifiedAt: item.modifiedAt, + pendingSync: pendingSync + ) + } +} + // MARK: - User Preferences @Model diff --git a/SportsTime/Core/Services/AppDelegate.swift b/SportsTime/Core/Services/AppDelegate.swift new file mode 100644 index 0000000..134475d --- /dev/null +++ b/SportsTime/Core/Services/AppDelegate.swift @@ -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) + } + } +} diff --git a/SportsTime/Core/Services/CustomItemService.swift b/SportsTime/Core/Services/CustomItemService.swift new file mode 100644 index 0000000..1b1715b --- /dev/null +++ b/SportsTime/Core/Services/CustomItemService.swift @@ -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) 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)" + } + } +} diff --git a/SportsTime/Core/Services/CustomItemSubscriptionService.swift b/SportsTime/Core/Services/CustomItemSubscriptionService.swift new file mode 100644 index 0000000..264706f --- /dev/null +++ b/SportsTime/Core/Services/CustomItemSubscriptionService.swift @@ -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() + var changePublisher: AnyPublisher { + changeSubject.eraseToAnyPublisher() + } + + /// Track which trip IDs have active subscriptions + private var subscribedTripIds: Set = [] + + 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() + } +} diff --git a/SportsTime/Features/Home/Views/HomeView.swift b/SportsTime/Features/Home/Views/HomeView.swift index 21ffd50..14d24c2 100644 --- a/SportsTime/Features/Home/Views/HomeView.swift +++ b/SportsTime/Features/Home/Views/HomeView.swift @@ -315,7 +315,7 @@ struct SavedTripCard: View { var body: some View { NavigationLink { - TripDetailView(trip: trip) + TripDetailView(trip: trip, games: savedTrip.games, allowCustomItems: true) } label: { HStack(spacing: Theme.Spacing.md) { // Route preview icon @@ -555,7 +555,7 @@ struct SavedTripsListView: View { ForEach(Array(sortedTrips.enumerated()), id: \.element.id) { index, savedTrip in if let trip = savedTrip.trip { NavigationLink { - TripDetailView(trip: trip) + TripDetailView(trip: trip, games: savedTrip.games, allowCustomItems: true) } label: { SavedTripListRow(trip: trip) } diff --git a/SportsTime/Features/Polls/Views/PollDetailView.swift b/SportsTime/Features/Polls/Views/PollDetailView.swift index 911d045..d84747c 100644 --- a/SportsTime/Features/Polls/Views/PollDetailView.swift +++ b/SportsTime/Features/Polls/Views/PollDetailView.swift @@ -126,7 +126,7 @@ struct PollDetailView: View { } .sheet(item: $selectedTrip) { trip in NavigationStack { - TripDetailView(trip: trip) + TripDetailView(trip: trip, allowCustomItems: true) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .cancellationAction) { diff --git a/SportsTime/Features/Trip/Views/AddItemSheet.swift b/SportsTime/Features/Trip/Views/AddItemSheet.swift new file mode 100644 index 0000000..3f0181a --- /dev/null +++ b/SportsTime/Features/Trip/Views/AddItemSheet.swift @@ -0,0 +1,149 @@ +// +// AddItemSheet.swift +// SportsTime +// +// Sheet for adding/editing custom itinerary items +// + +import SwiftUI + +struct AddItemSheet: View { + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + let tripId: UUID + let anchorDay: Int + let anchorType: CustomItineraryItem.AnchorType + let anchorId: String? + let existingItem: CustomItineraryItem? + var onSave: (CustomItineraryItem) -> Void + + @State private var selectedCategory: CustomItineraryItem.ItemCategory = .restaurant + @State private var title: String = "" + @State private var isSaving = false + + private var isEditing: Bool { existingItem != nil } + + var body: some View { + NavigationStack { + VStack(spacing: Theme.Spacing.lg) { + // Category picker + categoryPicker + + // Title input + TextField("What's the plan?", text: $title) + .textFieldStyle(.roundedBorder) + .font(.body) + + Spacer() + } + .padding() + .background(Theme.backgroundGradient(colorScheme)) + .navigationTitle(isEditing ? "Edit Item" : "Add Item") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + ToolbarItem(placement: .confirmationAction) { + Button(isEditing ? "Save" : "Add") { + saveItem() + } + .disabled(title.trimmingCharacters(in: .whitespaces).isEmpty || isSaving) + } + } + .onAppear { + if let existing = existingItem { + selectedCategory = existing.category + title = existing.title + } + } + } + } + + @ViewBuilder + private var categoryPicker: some View { + HStack(spacing: Theme.Spacing.md) { + ForEach(CustomItineraryItem.ItemCategory.allCases, id: \.self) { category in + CategoryButton( + category: category, + isSelected: selectedCategory == category + ) { + selectedCategory = category + } + } + } + } + + private func saveItem() { + let trimmedTitle = title.trimmingCharacters(in: .whitespaces) + guard !trimmedTitle.isEmpty else { return } + + isSaving = true + + let item: CustomItineraryItem + if let existing = existingItem { + item = CustomItineraryItem( + id: existing.id, + tripId: existing.tripId, + category: selectedCategory, + title: trimmedTitle, + anchorType: existing.anchorType, + anchorId: existing.anchorId, + anchorDay: existing.anchorDay, + createdAt: existing.createdAt, + modifiedAt: Date() + ) + } else { + item = CustomItineraryItem( + tripId: tripId, + category: selectedCategory, + title: trimmedTitle, + anchorType: anchorType, + anchorId: anchorId, + anchorDay: anchorDay + ) + } + + onSave(item) + dismiss() + } +} + +// MARK: - Category Button + +private struct CategoryButton: View { + let category: CustomItineraryItem.ItemCategory + let isSelected: Bool + let action: () -> Void + + var body: some View { + Button(action: action) { + VStack(spacing: 4) { + Text(category.icon) + .font(.title2) + Text(category.label) + .font(.caption2) + } + .frame(maxWidth: .infinity) + .padding(.vertical, 12) + .background(isSelected ? Theme.warmOrange.opacity(0.2) : Color.clear) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(isSelected ? Theme.warmOrange : Color.secondary.opacity(0.3), lineWidth: isSelected ? 2 : 1) + ) + } + .buttonStyle(.plain) + } +} + +#Preview { + AddItemSheet( + tripId: UUID(), + anchorDay: 1, + anchorType: .startOfDay, + anchorId: nil, + existingItem: nil + ) { _ in } +} diff --git a/SportsTime/Features/Trip/Views/CustomItemRow.swift b/SportsTime/Features/Trip/Views/CustomItemRow.swift new file mode 100644 index 0000000..01fe624 --- /dev/null +++ b/SportsTime/Features/Trip/Views/CustomItemRow.swift @@ -0,0 +1,89 @@ +// +// CustomItemRow.swift +// SportsTime +// +// Row component for custom itinerary items with drag handle +// + +import SwiftUI + +struct CustomItemRow: View { + @Environment(\.colorScheme) private var colorScheme + + let item: CustomItineraryItem + var onTap: () -> Void + var onDelete: () -> Void + + // Drag handle visible - users can drag to reorder using .draggable/.dropDestination + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + // Drag handle + Image(systemName: "line.3.horizontal") + .foregroundStyle(.tertiary) + .font(.caption) + + // Category icon + Text(item.category.icon) + .font(.title3) + + // Title + Text(item.title) + .font(.subheadline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .lineLimit(2) + + Spacer() + + // Chevron to indicate tappable (tap to edit) + Image(systemName: "chevron.right") + .foregroundStyle(.tertiary) + .font(.caption) + } + .padding(.horizontal, Theme.Spacing.md) + .padding(.vertical, Theme.Spacing.sm) + .background(Theme.warmOrange.opacity(0.08)) + .cornerRadius(8) + } + .buttonStyle(.plain) + .contextMenu { + Button { + onTap() + } label: { + Label("Edit", systemImage: "pencil") + } + Button(role: .destructive) { + onDelete() + } label: { + Label("Delete", systemImage: "trash") + } + } + } +} + +#Preview { + VStack { + CustomItemRow( + item: CustomItineraryItem( + tripId: UUID(), + category: .restaurant, + title: "Joe's BBQ - Best brisket in Texas!", + anchorDay: 1 + ), + onTap: {}, + onDelete: {} + ) + CustomItemRow( + item: CustomItineraryItem( + tripId: UUID(), + category: .hotel, + title: "Hilton Downtown", + anchorDay: 1 + ), + onTap: {}, + onDelete: {} + ) + } + .padding() +} diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 8f47d7d..ad7ee10 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -6,6 +6,7 @@ import SwiftUI import SwiftData import MapKit +import Combine struct TripDetailView: View { @Environment(\.modelContext) private var modelContext @@ -14,6 +15,9 @@ struct TripDetailView: View { let trip: Trip private let providedGames: [String: RichGame]? + /// When true, shows Add buttons and custom items (only for saved trips) + private let allowCustomItems: Bool + @Query private var savedTrips: [SavedTrip] @State private var showProPaywall = false @State private var selectedDay: ItineraryDay? @@ -28,6 +32,13 @@ struct TripDetailView: View { @State private var loadedGames: [String: RichGame] = [:] @State private var isLoadingGames = false + // Custom items state + @State private var customItems: [CustomItineraryItem] = [] + @State private var addItemAnchor: AddItemAnchor? + @State private var editingItem: CustomItineraryItem? + @State private var subscriptionCancellable: AnyCancellable? + @State private var draggedItem: CustomItineraryItem? + private let exportService = ExportService() private let dataProvider = AppDataProvider.shared @@ -36,16 +47,32 @@ struct TripDetailView: View { providedGames ?? loadedGames } - /// Initialize with trip and games dictionary (existing callers) + /// Initialize with trip and games dictionary (existing callers - no custom items) init(trip: Trip, games: [String: RichGame]) { self.trip = trip self.providedGames = games + self.allowCustomItems = false } - /// Initialize with just trip - games will be loaded from AppDataProvider + /// Initialize with just trip - games will be loaded from AppDataProvider (no custom items) init(trip: Trip) { self.trip = trip self.providedGames = nil + self.allowCustomItems = false + } + + /// Initialize for saved trip context - enables custom items + init(trip: Trip, games: [String: RichGame], allowCustomItems: Bool) { + self.trip = trip + self.providedGames = games + self.allowCustomItems = allowCustomItems + } + + /// Initialize for saved trip context without provided games - loads from AppDataProvider + init(trip: Trip, allowCustomItems: Bool) { + self.trip = trip + self.providedGames = nil + self.allowCustomItems = allowCustomItems } var body: some View { @@ -110,11 +137,40 @@ struct TripDetailView: View { .sheet(isPresented: $showProPaywall) { PaywallView() } + .sheet(item: $addItemAnchor) { anchor in + AddItemSheet( + tripId: trip.id, + anchorDay: anchor.day, + anchorType: anchor.type, + anchorId: anchor.anchorId, + existingItem: nil + ) { item in + Task { await saveCustomItem(item) } + } + } + .sheet(item: $editingItem) { item in + AddItemSheet( + tripId: trip.id, + anchorDay: item.anchorDay, + anchorType: item.anchorType, + anchorId: item.anchorId, + existingItem: item + ) { updatedItem in + Task { await saveCustomItem(updatedItem) } + } + } .onAppear { checkIfSaved() } .task { await loadGamesIfNeeded() + if allowCustomItems { + await loadCustomItems() + await setupSubscription() + } + } + .onDisappear { + subscriptionCancellable?.cancel() } .overlay { if isExporting { @@ -330,25 +386,165 @@ struct TripDetailView: View { Spacer() } } else { - ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in - switch section { - case .day(let dayNumber, let date, let gamesOnDay): - DaySection( - dayNumber: dayNumber, - date: date, - games: gamesOnDay - ) - .staggeredAnimation(index: index) - case .travel(let segment): - TravelSection(segment: segment) - .staggeredAnimation(index: index) - } + // ZStack with continuous vertical line behind all content + ZStack(alignment: .top) { + // Continuous vertical line down the center + Rectangle() + .fill(Theme.routeGold.opacity(0.4)) + .frame(width: 2) + .frame(maxHeight: .infinity) + + // Itinerary content + VStack(spacing: Theme.Spacing.md) { + ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in + itineraryRow(for: section, at: index) + } + } } } } } - /// Build itinerary sections: group by day AND city, with travel between different cities + @ViewBuilder + private func itineraryRow(for section: ItinerarySection, at index: Int) -> some View { + switch section { + case .day(let dayNumber, let date, let gamesOnDay): + DaySection( + dayNumber: dayNumber, + date: date, + games: gamesOnDay + ) + .staggeredAnimation(index: index) + .dropDestination(for: String.self) { items, _ in + guard let itemIdString = items.first, + let itemId = UUID(uuidString: itemIdString), + let item = customItems.first(where: { $0.id == itemId }), + let lastGame = gamesOnDay.last else { return false } + Task { + await moveItem(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id) + } + return true + } + + case .travel(let segment): + TravelSection(segment: segment) + .staggeredAnimation(index: index) + .dropDestination(for: String.self) { items, _ in + guard let itemIdString = items.first, + let itemId = UUID(uuidString: itemIdString), + let item = customItems.first(where: { $0.id == itemId }) else { return false } + // Find the day for this travel segment + let day = findDayForTravelSegment(segment) + Task { + await moveItem(item, toDay: day, anchorType: .afterTravel, anchorId: segment.id.uuidString) + } + return true + } + + case .customItem(let item): + CustomItemRow( + item: item, + onTap: { editingItem = item }, + onDelete: { Task { await deleteCustomItem(item) } } + ) + .staggeredAnimation(index: index) + .draggable(item.id.uuidString) { + // Drag preview + CustomItemRow( + item: item, + onTap: {}, + onDelete: {} + ) + .frame(width: 300) + .opacity(0.8) + } + .dropDestination(for: String.self) { items, _ in + guard let itemIdString = items.first, + let itemId = UUID(uuidString: itemIdString), + let draggedItem = customItems.first(where: { $0.id == itemId }), + draggedItem.id != item.id else { return false } + Task { + await moveItem(draggedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item) + } + return true + } + + case .addButton(let day, let anchorType, let anchorId): + InlineAddButton { + addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId) + } + .dropDestination(for: String.self) { items, _ in + guard let itemIdString = items.first, + let itemId = UUID(uuidString: itemIdString), + let item = customItems.first(where: { $0.id == itemId }) else { return false } + Task { + await moveItem(item, toDay: day, anchorType: anchorType, anchorId: anchorId) + } + return true + } + } + } + + private func findDayForTravelSegment(_ segment: TravelSegment) -> Int { + // Find which day this travel segment belongs to by looking at sections + for (index, section) in itinerarySections.enumerated() { + if case .travel(let s) = section, s.id == segment.id { + // Look backwards to find the day + for i in stride(from: index - 1, through: 0, by: -1) { + if case .day(let dayNumber, _, _) = itinerarySections[i] { + return dayNumber + } + } + } + } + return 1 + } + + private func moveItem(_ item: CustomItineraryItem, toDay day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?, beforeItem: CustomItineraryItem? = nil) async { + var updated = item + updated.anchorDay = day + updated.anchorType = anchorType + updated.anchorId = anchorId + + // Calculate sortOrder + let itemsAtSameAnchor = customItems.filter { + $0.anchorDay == day && + $0.anchorType == anchorType && + $0.anchorId == anchorId && + $0.id != item.id + }.sorted { $0.sortOrder < $1.sortOrder } + + if let beforeItem = beforeItem, + let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) { + updated.sortOrder = beforeIndex + // Shift other items + for i in beforeIndex.. Void + + var body: some View { + Button(action: action) { + HStack { + Image(systemName: "plus.circle.fill") + .foregroundStyle(Theme.warmOrange.opacity(0.6)) + Text("Add") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + } + .buttonStyle(.plain) + } } // MARK: - Day Section @@ -783,14 +1139,8 @@ struct TravelSection: View { } var body: some View { + // Travel card VStack(spacing: 0) { - // Top connector - Rectangle() - .fill(Theme.routeGold.opacity(0.4)) - .frame(width: 2, height: 16) - - // Travel card - VStack(spacing: 0) { // Main travel info HStack(spacing: Theme.Spacing.md) { // Icon @@ -875,12 +1225,6 @@ struct TravelSection: View { RoundedRectangle(cornerRadius: Theme.CornerRadius.medium) .strokeBorder(Theme.routeGold.opacity(0.3), lineWidth: 1) } - - // Bottom connector - Rectangle() - .fill(Theme.routeGold.opacity(0.4)) - .frame(width: 2, height: 16) - } } } diff --git a/SportsTime/SportsTimeApp.swift b/SportsTime/SportsTimeApp.swift index 7cb1a69..6cf60b1 100644 --- a/SportsTime/SportsTimeApp.swift +++ b/SportsTime/SportsTimeApp.swift @@ -11,6 +11,9 @@ import BackgroundTasks @main struct SportsTimeApp: App { + /// App delegate for handling push notifications + @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate + /// Task that listens for StoreKit transaction updates private var transactionListener: Task? diff --git a/SportsTimeTests/Domain/CustomItineraryItemTests.swift b/SportsTimeTests/Domain/CustomItineraryItemTests.swift new file mode 100644 index 0000000..02547cc --- /dev/null +++ b/SportsTimeTests/Domain/CustomItineraryItemTests.swift @@ -0,0 +1,57 @@ +// +// CustomItineraryItemTests.swift +// SportsTimeTests +// + +import Testing +@testable import SportsTime +import Foundation + +struct CustomItineraryItemTests { + + @Test("Item initializes with default values") + func item_InitializesWithDefaults() { + let tripId = UUID() + let item = CustomItineraryItem( + tripId: tripId, + category: .restaurant, + title: "Joe's BBQ", + anchorDay: 1 + ) + + #expect(item.tripId == tripId) + #expect(item.category == .restaurant) + #expect(item.title == "Joe's BBQ") + #expect(item.anchorType == .startOfDay) + #expect(item.anchorId == nil) + #expect(item.anchorDay == 1) + } + + @Test("Item category has correct icons") + func category_HasCorrectIcons() { + #expect(CustomItineraryItem.ItemCategory.restaurant.icon == "🍽️") + #expect(CustomItineraryItem.ItemCategory.hotel.icon == "🏨") + #expect(CustomItineraryItem.ItemCategory.activity.icon == "🎯") + #expect(CustomItineraryItem.ItemCategory.note.icon == "📝") + } + + @Test("Item is Codable") + func item_IsCodable() throws { + let item = CustomItineraryItem( + tripId: UUID(), + category: .hotel, + title: "Hilton Downtown", + anchorType: .afterGame, + anchorId: "game_123", + anchorDay: 2 + ) + + let encoded = try JSONEncoder().encode(item) + let decoded = try JSONDecoder().decode(CustomItineraryItem.self, from: encoded) + + #expect(decoded.id == item.id) + #expect(decoded.title == item.title) + #expect(decoded.anchorType == .afterGame) + #expect(decoded.anchorId == "game_123") + } +}