From b534ca771ba0b3ba0b6e7cde5eebebd358ddb7a0 Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 15 Jan 2026 23:07:18 -0600 Subject: [PATCH 1/4] docs: add custom itinerary items design Design for allowing users to add personal items (restaurants, hotels, activities, notes) to saved trip itineraries with drag-to-reorder and CloudKit sync. Co-Authored-By: Claude Opus 4.5 --- ...026-01-15-custom-itinerary-items-design.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/plans/2026-01-15-custom-itinerary-items-design.md diff --git a/docs/plans/2026-01-15-custom-itinerary-items-design.md b/docs/plans/2026-01-15-custom-itinerary-items-design.md new file mode 100644 index 0000000..1367441 --- /dev/null +++ b/docs/plans/2026-01-15-custom-itinerary-items-design.md @@ -0,0 +1,195 @@ +# Custom Itinerary Items Design + +**Date:** 2026-01-15 +**Status:** Approved + +## Overview + +Allow users to add custom items (restaurants, hotels, activities, notes) to saved itineraries. Items can be repositioned anywhere among the system-generated content (games, travel segments) but system items remain fixed. + +## Requirements + +- Categorized items with visual icons (Restaurant, Hotel, Activity, Note) +- Inline "Add" buttons between each itinerary section +- Always-visible drag handles on custom items (system items have no handles) +- CloudKit sync across devices +- Swipe to delete, tap to edit + +## Data Model + +```swift +struct CustomItineraryItem: Identifiable, Codable, Hashable { + let id: UUID + let tripId: UUID + var category: ItemCategory + var title: String + var anchorType: AnchorType // What this item is positioned after + var anchorId: String? // ID of game or travel segment (nil = start of day) + var anchorDay: Int // Day number (1-based) + let createdAt: Date + var modifiedAt: Date + + enum ItemCategory: String, Codable, CaseIterable { + case restaurant, hotel, activity, 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" + } + } + } + + enum AnchorType: String, Codable { + case startOfDay // Before any games/travel on this day + case afterGame // After a specific game + case afterTravel // After a specific travel segment + } +} +``` + +**Why anchor-based positioning?** If a game gets rescheduled or removed, index-based positions would break. Anchoring to a specific game/travel ID is more stable. If the anchor is deleted, the item falls back to `startOfDay`. + +## UI Components + +### Inline "Add Item" Buttons + +Small, subtle `+ Add` buttons appear between each itinerary section: + +``` +Day 1 - Arlington +β”œβ”€ 🎾 SFG @ TEX (12:05 AM) +β”œβ”€ [+ Add] +β”œβ”€ πŸš— Drive to Milwaukee +β”œβ”€ [+ Add] +└─ 🎾 PIT @ MIL (7:10 PM) + [+ Add] + +Day 2 - Chicago +β”œβ”€ [+ Add] ← Start of day +β”œβ”€ 🎾 LAD @ CHC (1:20 PM) + [+ Add] +``` + +### Add Item Sheet + +A simple modal with: +- Category picker (4 icons in a horizontal row, tap to select) +- Text field for title +- "Add" button + +### Custom Item Row + +Displays in the itinerary with: +- Category icon on the left +- Title text +- Drag handle (≑) on the right - always visible +- Swipe left reveals red "Delete" action +- Tap opens edit sheet (same as add, pre-filled) +- Subtle warm background tint for visual distinction + +## Reordering Logic + +Games and travel segments are fixed. Only custom items can be dragged. + +### How it works + +1. The itinerary renders as a flat list of mixed item types +2. When a custom item is dragged, it can only be dropped into valid "slots" between system items +3. On drop, the item's anchor is updated to reference what it's now positioned after + +### Implementation approach + + + +Use custom drag-and-drop with `draggable()` and `dropDestination()` modifiers rather than `.onMove`. This gives precise control over what can be dragged and where it can land. + +```swift +ForEach(itinerarySections) { section in + switch section { + case .game, .travel: + // Render normally, no drag handle + // But IS a drop destination + case .customItem: + // Render with drag handle + // draggable() + dropDestination() + case .addButton: + // Inline add button (also a drop destination) + } +} +``` + +## CloudKit Sync + +### New record type: `CustomItineraryItem` + +Fields: +- `itemId` (String) - UUID +- `tripId` (String) - References the SavedTrip +- `category` (String) - restaurant/hotel/activity/note +- `title` (String) +- `anchorType` (String) +- `anchorId` (String, optional) +- `anchorDay` (Int) +- `createdAt` (Date) +- `modifiedAt` (Date) + +### Sync approach + +Follows the same pattern as `PollVote`: +- `CKCustomItineraryItem` wrapper struct in `CKModels.swift` +- CRUD methods in a new `CustomItemService` actor +- Items are fetched when loading a saved trip +- Changes sync immediately on add/edit/delete/reorder + +### Conflict resolution + +Last-write-wins based on `modifiedAt` for both content edits and position changes. + +### Offline handling + +Custom items are also stored locally in SwiftData alongside `SavedTrip`. When offline, changes queue locally and sync when connection restores. + +## Files to Create + +| File | Purpose | +|------|---------| +| `Core/Models/Domain/CustomItineraryItem.swift` | Domain model | +| `Core/Models/CloudKit/CKCustomItineraryItem.swift` | CloudKit wrapper | +| `Core/Services/CustomItemService.swift` | CloudKit CRUD operations | +| `Features/Trip/Views/AddItemSheet.swift` | Modal for adding/editing items | +| `Features/Trip/Views/CustomItemRow.swift` | Row component with drag handle | + +## Files to Modify + +| File | Changes | +|------|---------| +| `TripDetailView.swift` | Add inline buttons, render custom items, handle drag/drop | +| `SavedTrip.swift` | Add relationship to custom items (local cache) | +| `CanonicalSyncService.swift` | Sync custom items with CloudKit | + +## Out of Scope (YAGNI) + +- Time/duration fields on custom items +- Location/address fields +- Photos/attachments +- Sharing custom items in polls +- Notifications/reminders for custom items + +These can be added in future iterations if needed. From 495ef883038ba8068deb42bd93b382d15d6b5b52 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 16 Jan 2026 00:31:44 -0600 Subject: [PATCH 2/4] 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 --- .../Core/Models/CloudKit/CKModels.swift | 72 ++++ .../Models/Domain/CustomItineraryItem.swift | 83 ++++ SportsTime/Core/Models/Local/SavedTrip.swift | 73 ++++ SportsTime/Core/Services/AppDelegate.swift | 48 +++ .../Core/Services/CustomItemService.swift | 204 +++++++++ .../CustomItemSubscriptionService.swift | 147 +++++++ SportsTime/Features/Home/Views/HomeView.swift | 4 +- .../Features/Polls/Views/PollDetailView.swift | 2 +- .../Features/Trip/Views/AddItemSheet.swift | 149 +++++++ .../Features/Trip/Views/CustomItemRow.swift | 89 ++++ .../Features/Trip/Views/TripDetailView.swift | 404 ++++++++++++++++-- SportsTime/SportsTimeApp.swift | 3 + .../Domain/CustomItineraryItemTests.swift | 57 +++ 13 files changed, 1302 insertions(+), 33 deletions(-) create mode 100644 SportsTime/Core/Models/Domain/CustomItineraryItem.swift create mode 100644 SportsTime/Core/Services/AppDelegate.swift create mode 100644 SportsTime/Core/Services/CustomItemService.swift create mode 100644 SportsTime/Core/Services/CustomItemSubscriptionService.swift create mode 100644 SportsTime/Features/Trip/Views/AddItemSheet.swift create mode 100644 SportsTime/Features/Trip/Views/CustomItemRow.swift create mode 100644 SportsTimeTests/Domain/CustomItineraryItemTests.swift 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") + } +} From ae88dd3807c42597befd2f8c336fb49eb73b33d3 Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 16 Jan 2026 00:32:14 -0600 Subject: [PATCH 3/4] i dunno --- ClaudeCommands.md | 403 +++++ Scripts/.claude/settings.local.json | 8 + TO-DOS.md | 31 - ...5-custom-itinerary-items-implementation.md | 1291 +++++++++++++++++ 4 files changed, 1702 insertions(+), 31 deletions(-) create mode 100644 ClaudeCommands.md create mode 100644 Scripts/.claude/settings.local.json create mode 100644 docs/plans/2026-01-15-custom-itinerary-items-implementation.md diff --git a/ClaudeCommands.md b/ClaudeCommands.md new file mode 100644 index 0000000..4f0277d --- /dev/null +++ b/ClaudeCommands.md @@ -0,0 +1,403 @@ +``` +Read TO-DOS.md in full. + Summarize: + - Goal + - Current Phase + - Active Tasks + Do not write code until this summary is complete. +``` + +``` +/superpowers:brainstorm todo X <-- will create a design doc in the docs/plan +``` + +``` +/superpowers:write-plan for the design created +``` + +``` +/superpowers:subagent-driven-development +``` + +``` +read docs/TEST_PLAN.md in full + + Summarize: + - Goal + - Current Phase + - Active Tasks + Do not write code until this summary is complete. +``` + +``` +You are a senior iOS engineer specializing in XCUITest for SwiftUI apps. + +Your job is to write production-grade, CI-stable XCUITests. +Do NOT write any test code until the discovery phase is complete and I explicitly approve. + +PHASE 1 β€” DISCOVERY (MANDATORY) +Before writing any code, you must: + +1. Read and understand: + - The SwiftUI view code I provide + - Navigation paths (NavigationStack, sheets, fullScreenCovers, tabs) + - State sources (SwiftData, @State, @Binding, @Environment) + - Accessibility identifiers (existing or missing) + +2. Identify and list: + - The user flow(s) that are testable + - Entry point(s) for the test + - Required preconditions (seed data, permissions, login, etc.) + - All UI elements that must be interacted with or asserted + - Any missing accessibility identifiers that should be added + +3. Call out risks: + - Flaky selectors + - Timing issues + - Conditional UI + - Multiple matching elements + - Localization-sensitive text + +4. Ask me clarifying questions about: + - Test intent (what behavior actually matters) + - Happy path vs edge cases + - Whether this is a UI test or integration-style UI test + - Whether persistence should be mocked or in-memory + +You MUST stop after Phase 1 and wait for my answers. +Do not speculate. Do not guess identifiers. + +--- + +PHASE 2 β€” TEST PLAN (REQUIRED) +After I answer, produce a concise test plan that includes: +- Test name(s) +- Given / When / Then for each test +- Assertions (what must be true, not just visible) +- Setup and teardown strategy +- Any helper methods you intend to create + +Stop and wait for approval before writing code. + +--- + +PHASE 3 β€” IMPLEMENTATION (ONLY AFTER APPROVAL) +When approved, write the XCUITest with these rules: + +- Prefer accessibilityIdentifier over labels +- No element(boundBy:) +- No firstMatch unless justified +- Use waitForExistence with explicit timeouts +- No sleep() +- Assert state changes, not just existence +- Tests must be deterministic and CI-safe +- Extract helpers for navigation and setup +- Comment non-obvious waits or assertions + +If required information is missing at any point, STOP and ask. + +Begin in Phase 1 once I provide the view code. +``` +``` +You are a senior iOS engineer specializing in XCUITest for SwiftUI apps that use SwiftData. + +Your responsibility is to produce CI-stable, deterministic UI tests. +You must follow a gated workflow. Do NOT write test code until explicitly authorized. + +================================================ +PHASE 1 β€” SWIFTDATA DISCOVERY (MANDATORY) +================================================ + +Before writing any test code, you must: + +1. Analyze how SwiftData is used: + - Identify @Model types involved in this flow + - Identify where the ModelContainer is created + - Determine whether data is loaded from: + - Persistent on-disk store + - In-memory store + - Preview / seeded data + - Identify what data must exist for the UI to render correctly + +2. Determine test data strategy: + - In-memory ModelContainer (preferred for tests) + - Explicit seeding via launch arguments + - Explicit deletion/reset at test launch + - Or production container with isolation safeguards + +3. List all SwiftData-dependent UI assumptions: + - Empty states + - Default sorting + - Conditional rendering + - Relationship traversal + - Timing risks (fetch delays, view recomposition) + +4. Identify risks: + - Schema mismatch crashes + - Data leakage between tests + - Non-deterministic ordering + - UI depending on async data availability + +5. Ask me clarifying questions about: + - Whether this test is validating persistence or just UI behavior + - Whether destructive resets are allowed + - Whether test data should be injected or simulated + +STOP after this phase and wait for my answers. +Do not speculate. Do not invent data. + +================================================ +PHASE 2 β€” TEST PLAN +================================================ + +After clarification, propose: +- Test name(s) +- Given / When / Then +- Required seeded data +- Assertions tied to SwiftData-backed state +- Cleanup strategy + +WAIT for approval. + +================================================ +PHASE 3 β€” IMPLEMENTATION +================================================ + +Only after approval, write the XCUITest with these rules: +- Accessibility identifiers over labels +- No element(boundBy:) +- No firstMatch unless justified +- waitForExistence with explicit timeouts +- No sleep() +- Assert SwiftData-backed state transitions, not just visibility +- CI-safe and order-independent + +Begin Phase 1 once I provide context. +``` +``` +You are a senior iOS engineer writing XCUITests for a SwiftUI app. + +You will be given a sequence of screenshots representing a user flow: +A β†’ B β†’ C β†’ D β†’ E + +IMPORTANT CONSTRAINTS: +- Screenshots are visual evidence, not implementation truth +- You may infer navigation flow, but you may NOT invent: + - Accessibility identifiers + - View names + - File names + - Data models +- You must ask clarifying questions before writing code + +================================================ +PHASE 1 β€” VISUAL FLOW ANALYSIS (MANDATORY) +================================================ + +From the screenshots, you must: + +1. Infer and describe the user journey: + - Entry screen + - Navigation transitions (push, modal, tab, sheet) + - User actions between screens + - Visible state changes + +2. Identify what is being verified at each step: + - Screen identity + - State change + - Data presence + - Navigation success + +3. Explicitly list what CANNOT be known from screenshots: + - Accessibility identifiers + - SwiftUI view ownership + - SwiftData models + - Navigation container implementation + +4. Produce a numbered flow: + Step 1: Screen A β†’ Action β†’ Expected Result + Step 2: Screen B β†’ Action β†’ Expected Result + ... + Step N: Screen E β†’ Final Assertions + +5. Ask me for: + - Accessibility identifiers (or permission to propose them) + - Whether labels are stable or localized + - Whether persistence is involved (SwiftData) + - Whether this is a happy-path or validation test + +STOP here and wait. +Do NOT write code. + +================================================ +PHASE 2 β€” TEST STRATEGY PROPOSAL +================================================ + +After clarification, propose: +- Test intent +- Test name +- Required identifiers +- Assertions at each step +- SwiftData handling strategy (if applicable) + +WAIT for approval. + +================================================ +PHASE 3 β€” XCUITEST IMPLEMENTATION +================================================ + +Only after approval: +- Write the test from A β†’ E +- Verify state at each step +- Use explicit waits +- Avoid fragile selectors +- Extract helpers for navigation +- Comment assumptions derived from screenshots + +If anything is ambiguous, STOP and ask. +``` +``` +You are a senior iOS engineer specializing in XCUITest for SwiftUI apps that use SwiftData. + +Your task is to write production-grade, CI-stable XCUITests by analyzing: +- A sequence of screenshots representing a user flow (A β†’ B β†’ C β†’ D β†’ E) +- Any additional context I provide + +You must follow a gated, multi-phase workflow. +DO NOT write any test code until I explicitly approve. + +==================================================== +GLOBAL RULES (NON-NEGOTIABLE) +==================================================== + +- Screenshots are visual evidence, not implementation truth +- Do NOT invent: + - Accessibility identifiers + - View names or file names + - SwiftData models or relationships +- Prefer asking questions over guessing +- Treat SwiftData persistence as a source of flakiness unless constrained +- Assume tests run on CI across multiple simulators and iOS versions + +==================================================== +PHASE 1 β€” VISUAL FLOW ANALYSIS (MANDATORY) +==================================================== + +From the screenshots, you must: + +1. Infer and describe the user journey: + - Entry point (first screen) + - Navigation transitions between screens: + - push (NavigationStack) + - modal + - sheet + - tab switch + - User actions that cause each transition + +2. Produce a numbered flow: + Step 1: Screen A β†’ User Action β†’ Expected Result + Step 2: Screen B β†’ User Action β†’ Expected Result + ... + Final Step: Screen E β†’ Final Expected State + +3. Identify what is being validated at each step: + - Screen identity + - Navigation success + - Visible state change + - Data presence or mutation + +4. Explicitly list what CANNOT be known from screenshots: + - Accessibility identifiers + - SwiftUI view/file ownership + - SwiftData schema details + - Data source configuration + +STOP after Phase 1 and wait for confirmation. + +==================================================== +PHASE 2 β€” SWIFTDATA DISCOVERY (MANDATORY) +==================================================== + +Before any test planning, you must: + +1. Determine SwiftData involvement assumptions: + - Which screens appear to depend on persisted data + - Whether data is being created, edited, or merely displayed + - Whether ordering, filtering, or relationships are visible + +2. Identify SwiftData risks: + - Empty vs non-empty states + - Non-deterministic ordering + - Schema migration crashes + - Data leaking between tests + - Async fetch timing affecting UI + +3. Propose (do not assume) a test data strategy: + - In-memory ModelContainer (preferred) + - Explicit seeding via launch arguments + - Destructive reset at test launch + - Production container with isolation safeguards + +4. Ask me clarifying questions about: + - Whether persistence correctness is under test or just UI behavior + - Whether destructive resets are allowed in tests + - Whether test data may be injected or must use real flows + +STOP after this phase and wait. + +==================================================== +PHASE 3 β€” SELECTORS & ACCESSIBILITY CONTRACT +==================================================== + +Before writing code, you must: + +1. Request or propose accessibility identifiers for: + - Screens + - Interactive controls + - Assertion targets + +2. If proposing identifiers: + - Clearly label them as PROPOSED + - Group them by screen + - Explain why each is needed for test stability + +3. Identify selectors that would be fragile: + - Static text labels + - Localized strings + - firstMatch or indexed access + +STOP and wait for approval. + +==================================================== +PHASE 4 β€” TEST PLAN (REQUIRED) +==================================================== + +After clarification, produce a concise test plan containing: +- Test name(s) +- Given / When / Then for each test +- Preconditions and seeded data +- Assertions at each step (not just existence) +- Setup and teardown strategy +- Any helper abstractions you intend to create + +WAIT for explicit approval before proceeding. + +==================================================== +PHASE 5 β€” XCUITEST IMPLEMENTATION +==================================================== + +Only after approval, write the XCUITest with these rules: + +- Accessibility identifiers over labels +- No element(boundBy:) +- No firstMatch unless justified +- Explicit waitForExistence timeouts +- No sleep() +- Assert state changes, not just visibility +- Tests must be deterministic, isolated, and CI-safe +- Extract helpers for navigation and setup +- Comment assumptions inferred from screenshots + +If any ambiguity remains, STOP and ask. + +Begin Phase 1 once I provide the screenshots. +``` \ No newline at end of file diff --git a/Scripts/.claude/settings.local.json b/Scripts/.claude/settings.local.json new file mode 100644 index 0000000..6998ca7 --- /dev/null +++ b/Scripts/.claude/settings.local.json @@ -0,0 +1,8 @@ +{ + "permissions": { + "allow": [ + "Skill(superpowers:brainstorming)", + "Skill(superpowers:writing-plans)" + ] + } +} diff --git a/TO-DOS.md b/TO-DOS.md index 170477e..60a486b 100644 --- a/TO-DOS.md +++ b/TO-DOS.md @@ -1,41 +1,10 @@ -``` -Read TO-DOS.md in full. - Summarize: - - Goal - - Current Phase - - Active Tasks - Do not write code until this summary is complete. - -/superpowers:brainstorm todo X <-- will create a design doc in the docs/plan - -/superpowers:write-plan for the design created - -/superpowers:subagent-driven-development - -read docs/TEST_PLAN.md in full - - Summarize: - - Goal - - Current Phase - - Active Tasks - Do not write code until this summary is complete. -``` - question: do we need sync schedules anymore in settings // new dev - Notification reminders - "Your trip starts in 3 days" // enhancements -- Dark/light mode toggle along with system -- Stadium notes - let users add personal notes/tips to stadiums they've visited -sharing needs to completely overhauled. should be able to share a trip summary, achievements, progress. social media first -- Achievements share says 2,026. Should be an option when share is hit of year or all time -- if viewing a single sport share should share details regarding that sport only - need achievements for every supported league (how does this work with adding new sports in backed, might have to push with app update so if not achievements exist for that sport don’t show it as an option) -- Share needs more styling, similar to onboarding -- Ready to plan your trip has a summary, missing details should be in red - // bugs - fucking game show at 7 am ... the fuck? diff --git a/docs/plans/2026-01-15-custom-itinerary-items-implementation.md b/docs/plans/2026-01-15-custom-itinerary-items-implementation.md new file mode 100644 index 0000000..77bffee --- /dev/null +++ b/docs/plans/2026-01-15-custom-itinerary-items-implementation.md @@ -0,0 +1,1291 @@ +# Custom Itinerary Items Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Allow users to add, edit, reorder, and delete custom items (restaurants, hotels, activities, notes) within saved trip itineraries, with CloudKit sync. + +**Architecture:** Domain model with anchor-based positioning, CloudKit wrapper following CKPollVote pattern, CustomItemService actor for CRUD, SwiftData for local cache, UI integration in TripDetailView with drag/drop. + +**Tech Stack:** Swift, SwiftUI, SwiftData, CloudKit, draggable/dropDestination APIs + +--- + +## Task 1: Domain Model + +**Files:** +- Create: `SportsTime/Core/Models/Domain/CustomItineraryItem.swift` +- Test: `SportsTimeTests/Domain/CustomItineraryItemTests.swift` + +**Step 1: Create the domain model file** + +```swift +// +// 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 + let createdAt: Date + var modifiedAt: Date + + init( + id: UUID = UUID(), + tripId: UUID, + category: ItemCategory, + title: String, + anchorType: AnchorType = .startOfDay, + anchorId: String? = nil, + anchorDay: Int, + 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.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 + } +} +``` + +**Step 2: Create basic unit tests** + +```swift +// +// 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") + } +} +``` + +**Step 3: Run tests to verify** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' -only-testing:SportsTimeTests/CustomItineraryItemTests test` +Expected: PASS + +**Step 4: Commit** + +```bash +git add SportsTime/Core/Models/Domain/CustomItineraryItem.swift SportsTimeTests/Domain/CustomItineraryItemTests.swift +git commit -m "feat(models): add CustomItineraryItem domain model" +``` + +--- + +## Task 2: CloudKit Wrapper + +**Files:** +- Modify: `SportsTime/Core/Models/CloudKit/CKModels.swift` (add at end) + +**Step 1: Add CKRecordType constant** + +Find the `CKRecordType` extension and add: + +```swift +static let customItineraryItem: CKRecordType = "CustomItineraryItem" +``` + +**Step 2: Add CKCustomItineraryItem struct** + +Add at the end of CKModels.swift: + +```swift +// 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 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.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 + + return CustomItineraryItem( + id: itemId, + tripId: tripId, + category: category, + title: title, + anchorType: anchorType, + anchorId: anchorId, + anchorDay: anchorDay, + createdAt: createdAt, + modifiedAt: modifiedAt + ) + } +} +``` + +**Step 3: Build to verify** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` +Expected: BUILD SUCCEEDED + +**Step 4: Commit** + +```bash +git add SportsTime/Core/Models/CloudKit/CKModels.swift +git commit -m "feat(cloudkit): add CKCustomItineraryItem wrapper" +``` + +--- + +## Task 3: CustomItemService + +**Files:** +- Create: `SportsTime/Core/Services/CustomItemService.swift` + +**Step 1: Create the service** + +```swift +// +// 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 { + let ckItem = CKCustomItineraryItem(item: item) + + do { + try await publicDatabase.save(ckItem.record) + return item + } catch let error as CKError { + throw mapCloudKitError(error) + } catch { + 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.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) + } + } + + 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] { + 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) + + return results.compactMap { result in + guard case .success(let record) = result.1 else { return nil } + return CKCustomItineraryItem(record: record).toItem() + }.sorted { $0.anchorDay < $1.anchorDay } + } catch let error as CKError { + throw mapCloudKitError(error) + } catch { + 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)" + } + } +} +``` + +**Step 2: Build to verify** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add SportsTime/Core/Services/CustomItemService.swift +git commit -m "feat(services): add CustomItemService for CloudKit CRUD" +``` + +--- + +## Task 4: SwiftData Local Model + +**Files:** +- Modify: `SportsTime/Core/Models/Local/SavedTrip.swift` + +**Step 1: Add LocalCustomItem model** + +Add after the `TripVote` model: + +```swift +// 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 + ) + } +} +``` + +**Step 2: Build to verify** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add SportsTime/Core/Models/Local/SavedTrip.swift +git commit -m "feat(models): add LocalCustomItem SwiftData model" +``` + +--- + +## Task 5: AddItemSheet UI + +**Files:** +- Create: `SportsTime/Features/Trip/Views/AddItemSheet.swift` + +**Step 1: Create the sheet** + +```swift +// +// 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 } +} +``` + +**Step 2: Build to verify** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add SportsTime/Features/Trip/Views/AddItemSheet.swift +git commit -m "feat(ui): add AddItemSheet for custom itinerary items" +``` + +--- + +## Task 6: CustomItemRow UI + +**Files:** +- Create: `SportsTime/Features/Trip/Views/CustomItemRow.swift` + +**Step 1: Create the row component** + +```swift +// +// 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 + + /* + REORDERING OPTIONS (for future reference): + 1. Edit mode toggle - User taps "Edit" to show drag handles, then "Done" (like Reminders) + 2. Long-press to drag - No edit mode, just long-press and drag + 3. Always show handles - Custom items always have visible drag handle (CURRENT CHOICE) + */ + + var body: some View { + HStack(spacing: 12) { + // Category icon + Text(item.category.icon) + .font(.title3) + + // Title + Text(item.title) + .font(.subheadline) + .lineLimit(2) + + Spacer() + + // Drag handle - always visible + Image(systemName: "line.3.horizontal") + .foregroundStyle(.secondary) + .font(.subheadline) + } + .padding(.horizontal, Theme.Spacing.md) + .padding(.vertical, Theme.Spacing.sm) + .background(Theme.warmOrange.opacity(0.08)) + .cornerRadius(8) + .contentShape(Rectangle()) + .onTapGesture { + onTap() + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + 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() +} +``` + +**Step 2: Build to verify** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` +Expected: BUILD SUCCEEDED + +**Step 3: Commit** + +```bash +git add SportsTime/Features/Trip/Views/CustomItemRow.swift +git commit -m "feat(ui): add CustomItemRow with drag handle and swipe-to-delete" +``` + +--- + +## Task 7: TripDetailView Integration - Part 1 (State & Data Loading) + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift` + +**Step 1: Add state properties** + +Add these state properties near the existing `@State` declarations: + +```swift +@State private var customItems: [CustomItineraryItem] = [] +@State private var showAddItemSheet = false +@State private var editingItem: CustomItineraryItem? +@State private var addItemAnchor: (day: Int, type: CustomItineraryItem.AnchorType, id: String?)? = nil +``` + +**Step 2: Add loadCustomItems function** + +Add this function in the view: + +```swift +private func loadCustomItems() async { + guard let tripId = trip.id as UUID? else { return } + do { + customItems = try await CustomItemService.shared.fetchItems(forTripId: tripId) + } catch { + print("Failed to load custom items: \(error)") + } +} +``` + +**Step 3: Add task modifier to load items** + +In the body, add a `.task` modifier after the existing `.onAppear`: + +```swift +.task { + await loadCustomItems() +} +``` + +**Step 4: Build to verify** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` +Expected: BUILD SUCCEEDED + +**Step 5: Commit** + +```bash +git add SportsTime/Features/Trip/Views/TripDetailView.swift +git commit -m "feat(ui): add custom items state and loading to TripDetailView" +``` + +--- + +## Task 8: TripDetailView Integration - Part 2 (Inline Add Buttons) + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift` + +**Step 1: Create InlineAddButton component** + +Add this private struct inside or near TripDetailView: + +```swift +private struct InlineAddButton: View { + let action: () -> 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) + } +} +``` + +**Step 2: Update ItinerarySection enum** + +Find the `ItinerarySection` enum and add a new case: + +```swift +enum ItinerarySection { + case day(dayNumber: Int, date: Date, games: [RichGame]) + case travel(TravelSegment) + case customItem(CustomItineraryItem) + case addButton(day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) +} +``` + +**Step 3: Build to verify** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` +Expected: BUILD SUCCEEDED (may have warnings about unhandled cases - fix in next task) + +**Step 4: Commit** + +```bash +git add SportsTime/Features/Trip/Views/TripDetailView.swift +git commit -m "feat(ui): add InlineAddButton and update ItinerarySection enum" +``` + +--- + +## Task 9: TripDetailView Integration - Part 3 (Rendering Custom Items) + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift` + +**Step 1: Update itinerarySections to include custom items and add buttons** + +Replace the `itinerarySections` computed property to interleave custom items and add buttons. This is complex - the key is to: +1. For each day section, insert custom items anchored to that day +2. After each game/travel, insert any custom items anchored to it +3. Insert add buttons in the gaps + +```swift +private var itinerarySections: [ItinerarySection] { + var sections: [ItinerarySection] = [] + var dayCitySections: [(dayNumber: Int, date: Date, city: String, games: [RichGame])] = [] + let days = tripDays + + for (index, dayDate) in days.enumerated() { + let dayNum = index + 1 + let gamesOnDay = gamesOn(date: dayDate) + + guard !gamesOnDay.isEmpty else { continue } + + var gamesByCity: [(city: String, games: [RichGame])] = [] + for game in gamesOnDay { + let city = game.stadium.city + if let lastIndex = gamesByCity.indices.last, gamesByCity[lastIndex].city == city { + gamesByCity[lastIndex].games.append(game) + } else { + gamesByCity.append((city, [game])) + } + } + + for cityGroup in gamesByCity { + dayCitySections.append((dayNum, dayDate, cityGroup.city, cityGroup.games)) + } + } + + for (index, section) in dayCitySections.enumerated() { + // Travel before this section + if index > 0 { + let prevSection = dayCitySections[index - 1] + if prevSection.city != section.city { + if let travelSegment = findTravelSegment(from: prevSection.city, to: section.city) { + sections.append(.travel(travelSegment)) + // Add button after travel + sections.append(.addButton(day: section.dayNumber, anchorType: .afterTravel, anchorId: travelSegment.id.uuidString)) + // Custom items after this travel + let itemsAfterTravel = customItems.filter { + $0.anchorDay == section.dayNumber && + $0.anchorType == .afterTravel && + $0.anchorId == travelSegment.id.uuidString + } + for item in itemsAfterTravel { + sections.append(.customItem(item)) + } + } + } + } + + // Add button at start of day (before games) + sections.append(.addButton(day: section.dayNumber, anchorType: .startOfDay, anchorId: nil)) + + // Custom items at start of day + let itemsAtStart = customItems.filter { + $0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay + } + for item in itemsAtStart { + sections.append(.customItem(item)) + } + + // Day section + sections.append(.day(dayNumber: section.dayNumber, date: section.date, games: section.games)) + + // Add button after day's games + if let lastGame = section.games.last { + sections.append(.addButton(day: section.dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id)) + // Custom items after this game + let itemsAfterGame = customItems.filter { + $0.anchorDay == section.dayNumber && + $0.anchorType == .afterGame && + $0.anchorId == lastGame.game.id + } + for item in itemsAfterGame { + sections.append(.customItem(item)) + } + } + } + + return sections +} +``` + +**Step 2: Update the ForEach in itinerarySection to handle new cases** + +Find where `itinerarySections` is rendered and update the switch: + +```swift +ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in + switch section { + case .day(let dayNumber, let date, let games): + DaySection( + dayNumber: dayNumber, + date: date, + games: games, + trip: trip, + colorScheme: colorScheme + ) + .staggeredAnimation(index: index) + case .travel(let segment): + TravelSection(segment: segment) + .staggeredAnimation(index: index) + case .customItem(let item): + CustomItemRow( + item: item, + onTap: { editingItem = item }, + onDelete: { Task { await deleteItem(item) } } + ) + .staggeredAnimation(index: index) + case .addButton(let day, let anchorType, let anchorId): + InlineAddButton { + addItemAnchor = (day, anchorType, anchorId) + showAddItemSheet = true + } + } +} +``` + +**Step 3: Build to verify** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` +Expected: BUILD SUCCEEDED (may need to add missing functions in next task) + +**Step 4: Commit** + +```bash +git add SportsTime/Features/Trip/Views/TripDetailView.swift +git commit -m "feat(ui): render custom items and add buttons in itinerary" +``` + +--- + +## Task 10: TripDetailView Integration - Part 4 (CRUD Operations & Sheets) + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift` + +**Step 1: Add CRUD helper functions** + +```swift +private func saveItem(_ item: CustomItineraryItem) async { + do { + if customItems.contains(where: { $0.id == item.id }) { + // Update existing + let updated = try await CustomItemService.shared.updateItem(item) + if let index = customItems.firstIndex(where: { $0.id == updated.id }) { + customItems[index] = updated + } + } else { + // Create new + let created = try await CustomItemService.shared.createItem(item) + customItems.append(created) + } + } catch { + print("Failed to save item: \(error)") + } +} + +private func deleteItem(_ item: CustomItineraryItem) async { + do { + try await CustomItemService.shared.deleteItem(item.id) + customItems.removeAll { $0.id == item.id } + } catch { + print("Failed to delete item: \(error)") + } +} +``` + +**Step 2: Add sheet modifiers** + +Add these modifiers to the view body: + +```swift +.sheet(isPresented: $showAddItemSheet) { + if let anchor = addItemAnchor { + AddItemSheet( + tripId: trip.id, + anchorDay: anchor.day, + anchorType: anchor.type, + anchorId: anchor.id, + existingItem: nil + ) { item in + Task { await saveItem(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 saveItem(updatedItem) } + } +} +``` + +**Step 3: Build to verify** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` +Expected: BUILD SUCCEEDED + +**Step 4: Commit** + +```bash +git add SportsTime/Features/Trip/Views/TripDetailView.swift +git commit -m "feat(ui): add CRUD operations and sheets for custom items" +``` + +--- + +## Task 11: Drag and Drop (Optional Enhancement) + +**Files:** +- Modify: `SportsTime/Features/Trip/Views/TripDetailView.swift` +- Modify: `SportsTime/Features/Trip/Views/CustomItemRow.swift` + +**Note:** This task adds drag-and-drop reordering. It can be deferred if basic add/edit/delete is sufficient for now. + +**Step 1: Make CustomItemRow draggable** + +Update CustomItemRow to add `.draggable`: + +```swift +var body: some View { + HStack(spacing: 12) { + // ... existing content + } + // ... existing modifiers + .draggable(item.id.uuidString) { + // Drag preview + HStack { + Text(item.category.icon) + Text(item.title) + } + .padding(8) + .background(Theme.cardBackground(.dark)) + .cornerRadius(8) + } +} +``` + +**Step 2: Add drop destinations to add buttons** + +Update InlineAddButton: + +```swift +private struct InlineAddButton: View { + let day: Int + let anchorType: CustomItineraryItem.AnchorType + let anchorId: String? + let onAdd: () -> Void + let onDrop: (CustomItineraryItem.AnchorType, String?, Int) -> Void + + var body: some View { + Button(action: onAdd) { + // ... existing content + } + .dropDestination(for: String.self) { items, _ in + guard let itemIdString = items.first else { return false } + onDrop(anchorType, anchorId, day) + return true + } + } +} +``` + +**Step 3: Implement move logic** + +Add function to handle drops: + +```swift +private func moveItem(itemId: String, toAnchorType: CustomItineraryItem.AnchorType, toAnchorId: String?, toDay: Int) async { + guard let uuid = UUID(uuidString: itemId), + var item = customItems.first(where: { $0.id == uuid }) else { return } + + item.anchorType = toAnchorType + item.anchorId = toAnchorId + item.anchorDay = toDay + item.modifiedAt = Date() + + await saveItem(item) +} +``` + +**Step 4: Build and test** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' build` +Expected: BUILD SUCCEEDED + +**Step 5: Commit** + +```bash +git add SportsTime/Features/Trip/Views/TripDetailView.swift SportsTime/Features/Trip/Views/CustomItemRow.swift +git commit -m "feat(ui): add drag-and-drop reordering for custom items" +``` + +--- + +## Task 12: Final Integration Test + +**Step 1: Run full test suite** + +Run: `xcodebuild -project SportsTime.xcodeproj -scheme SportsTime -destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2' test` +Expected: All tests PASS + +**Step 2: Manual testing checklist** + +- [ ] Open a saved trip +- [ ] Tap "+ Add" button between items +- [ ] Select category and enter title +- [ ] Verify item appears in correct position +- [ ] Tap item to edit +- [ ] Swipe left to delete +- [ ] (If drag implemented) Drag item to new position +- [ ] Close and reopen trip - items persist +- [ ] Test on second device - items sync via CloudKit + +**Step 3: Final commit** + +```bash +git add -A +git commit -m "feat: complete custom itinerary items feature" +``` + +--- + +## Summary + +| Task | Description | Files | +|------|-------------|-------| +| 1 | Domain model | `CustomItineraryItem.swift`, tests | +| 2 | CloudKit wrapper | `CKModels.swift` | +| 3 | Service layer | `CustomItemService.swift` | +| 4 | SwiftData model | `SavedTrip.swift` | +| 5 | Add/Edit sheet | `AddItemSheet.swift` | +| 6 | Item row component | `CustomItemRow.swift` | +| 7-10 | TripDetailView integration | `TripDetailView.swift` | +| 11 | Drag and drop (optional) | Multiple | +| 12 | Integration testing | - | From aca394cefa2041f64beeec41ba3e331d31b76f0d Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 16 Jan 2026 15:35:09 -0600 Subject: [PATCH 4/4] fix(itinerary): improve drag-drop reordering with stable anchors and visual feedback - Add visual drop target indicator showing where items will land - Use stable travel anchor IDs (city names) instead of UUIDs that regenerate - Fix findDayForTravelSegment to look forward to arrival day, not backward - Add moveItemToBeginning() for inserting at position 0 when dropping on sections - Sort custom items by sortOrder in all filters - Sync shifted items to CloudKit after reorder - Add opaque backgrounds to CustomItemRow and TravelSection to hide timeline Co-Authored-By: Claude Opus 4.5 --- .../Features/Trip/Views/CustomItemRow.swift | 13 +- .../Features/Trip/Views/TripDetailView.swift | 234 ++++++++++++++++-- 2 files changed, 220 insertions(+), 27 deletions(-) diff --git a/SportsTime/Features/Trip/Views/CustomItemRow.swift b/SportsTime/Features/Trip/Views/CustomItemRow.swift index 01fe624..e59b406 100644 --- a/SportsTime/Features/Trip/Views/CustomItemRow.swift +++ b/SportsTime/Features/Trip/Views/CustomItemRow.swift @@ -43,8 +43,17 @@ struct CustomItemRow: View { } .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, Theme.Spacing.sm) - .background(Theme.warmOrange.opacity(0.08)) - .cornerRadius(8) + .background { + // Opaque base + semi-transparent accent + RoundedRectangle(cornerRadius: 8) + .fill(Theme.cardBackground(colorScheme)) + RoundedRectangle(cornerRadius: 8) + .fill(Theme.warmOrange.opacity(0.1)) + } + .overlay { + RoundedRectangle(cornerRadius: 8) + .strokeBorder(Theme.warmOrange.opacity(0.3), lineWidth: 1) + } } .buttonStyle(.plain) .contextMenu { diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index ad7ee10..d266233 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -7,6 +7,7 @@ import SwiftUI import SwiftData import MapKit import Combine +import UniformTypeIdentifiers struct TripDetailView: View { @Environment(\.modelContext) private var modelContext @@ -38,6 +39,7 @@ struct TripDetailView: View { @State private var editingItem: CustomItineraryItem? @State private var subscriptionCancellable: AnyCancellable? @State private var draggedItem: CustomItineraryItem? + @State private var dropTargetId: String? // Track which drop zone is being hovered private let exportService = ExportService() private let dataProvider = AppDataProvider.shared @@ -172,6 +174,11 @@ struct TripDetailView: View { .onDisappear { subscriptionCancellable?.cancel() } + .onChange(of: customItems) { _, _ in + // Clear drag state after items update (move completed) + draggedItem = nil + dropTargetId = nil + } .overlay { if isExporting { exportProgressOverlay @@ -407,6 +414,9 @@ struct TripDetailView: View { @ViewBuilder private func itineraryRow(for section: ItinerarySection, at index: Int) -> some View { + let sectionId = sectionIdentifier(for: section, at: index) + let isDropTarget = dropTargetId == sectionId && draggedItem != nil + switch section { case .day(let dayNumber, let date, let gamesOnDay): DaySection( @@ -415,39 +425,68 @@ struct TripDetailView: View { games: gamesOnDay ) .staggeredAnimation(index: index) + .overlay(alignment: .bottom) { + if isDropTarget { + DropTargetIndicator() + } + } .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) + // Insert at beginning (sortOrder 0) when dropping on day card + await moveItemToBeginning(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id) } return true + } isTargeted: { targeted in + withAnimation(.easeInOut(duration: 0.2)) { + dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId) + } } case .travel(let segment): TravelSection(segment: segment) .staggeredAnimation(index: index) + .overlay(alignment: .bottom) { + if isDropTarget { + DropTargetIndicator() + } + } .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) + // Use stable identifier instead of UUID (UUIDs change on reload) + let stableAnchorId = stableTravelAnchorId(segment) Task { - await moveItem(item, toDay: day, anchorType: .afterTravel, anchorId: segment.id.uuidString) + // Insert at beginning when dropping on travel section + await moveItemToBeginning(item, toDay: day, anchorType: .afterTravel, anchorId: stableAnchorId) } return true + } isTargeted: { targeted in + withAnimation(.easeInOut(duration: 0.2)) { + dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId) + } } case .customItem(let item): + let isDragging = draggedItem?.id == item.id CustomItemRow( item: item, onTap: { editingItem = item }, onDelete: { Task { await deleteCustomItem(item) } } ) + .opacity(isDragging ? 0.4 : 1.0) .staggeredAnimation(index: index) + .overlay(alignment: .top) { + if isDropTarget && !isDragging { + DropTargetIndicator() + } + } .draggable(item.id.uuidString) { // Drag preview CustomItemRow( @@ -456,41 +495,75 @@ struct TripDetailView: View { onDelete: {} ) .frame(width: 300) - .opacity(0.8) + .opacity(0.9) + .onAppear { draggedItem = item } } .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 } + let droppedItem = customItems.first(where: { $0.id == itemId }), + droppedItem.id != item.id else { return false } Task { - await moveItem(draggedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item) + await moveItem(droppedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item) } + draggedItem = nil + dropTargetId = nil return true + } isTargeted: { targeted in + withAnimation(.easeInOut(duration: 0.2)) { + dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId) + } } case .addButton(let day, let anchorType, let anchorId): - InlineAddButton { - addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId) + VStack(spacing: 0) { + if isDropTarget { + DropTargetIndicator() + } + 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) + // Insert at beginning when dropping on add button area + await moveItemToBeginning(item, toDay: day, anchorType: anchorType, anchorId: anchorId) } + draggedItem = nil + dropTargetId = nil return true + } isTargeted: { targeted in + withAnimation(.easeInOut(duration: 0.2)) { + dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId) + } } } } + /// Create a stable identifier for an itinerary section (for drop target tracking) + private func sectionIdentifier(for section: ItinerarySection, at index: Int) -> String { + switch section { + case .day(let dayNumber, _, _): + return "day-\(dayNumber)" + case .travel(let segment): + return "travel-\(segment.fromLocation.name)-\(segment.toLocation.name)" + case .customItem(let item): + return "item-\(item.id.uuidString)" + case .addButton(let day, let anchorType, _): + return "add-\(day)-\(anchorType.rawValue)" + } + } + private func findDayForTravelSegment(_ segment: TravelSegment) -> Int { // Find which day this travel segment belongs to by looking at sections + // Travel appears BEFORE the arrival day, so look FORWARD to find arrival day 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) { + // Look forward to find the arrival day + for i in (index + 1).. String { + let from = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces) + let to = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces) + return "travel:\(from)->\(to)" + } + 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 + updated.modifiedAt = Date() // Calculate sortOrder let itemsAtSameAnchor = customItems.filter { @@ -514,20 +595,33 @@ struct TripDetailView: View { $0.id != item.id }.sorted { $0.sortOrder < $1.sortOrder } + var itemsToSync: [CustomItineraryItem] = [] + + print("πŸ“ [Move] itemsAtSameAnchor: \(itemsAtSameAnchor.map { "\($0.title) (id: \($0.id.uuidString.prefix(8)))" })") + if let beforeItem = beforeItem { + print("πŸ“ [Move] beforeItem: \(beforeItem.title) (id: \(beforeItem.id.uuidString.prefix(8)))") + } + if let beforeItem = beforeItem, let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) { updated.sortOrder = beforeIndex - // Shift other items + print("πŸ“ [Move] Setting \(item.title) sortOrder to \(beforeIndex) (before \(beforeItem.title))") + + // Shift other items and track them for syncing for i in beforeIndex..