diff --git a/SportsTime/Core/Models/CloudKit/CKModels.swift b/SportsTime/Core/Models/CloudKit/CKModels.swift index b1b3ede..15a3e43 100644 --- a/SportsTime/Core/Models/CloudKit/CKModels.swift +++ b/SportsTime/Core/Models/CloudKit/CKModels.swift @@ -20,8 +20,7 @@ enum CKRecordType { static let stadiumAlias = "StadiumAlias" static let tripPoll = "TripPoll" static let pollVote = "PollVote" - static let customItineraryItem = "CustomItineraryItem" - static let travelDayOverride = "TravelDayOverride" + static let itineraryItem = "ItineraryItem" } // MARK: - CKTeam @@ -625,155 +624,3 @@ struct CKPollVote { } } -// MARK: - CKCustomItineraryItem - -struct CKCustomItineraryItem { - static let itemIdKey = "itemId" - static let tripIdKey = "tripId" - static let categoryKey = "category" - static let titleKey = "title" - static let dayKey = "day" // NEW: replaces anchorDay - static let sortOrderDoubleKey = "sortOrderDouble" // NEW: Double instead of Int - static let createdAtKey = "createdAt" - static let modifiedAtKey = "modifiedAt" - // Location fields for mappable items - static let latitudeKey = "latitude" - static let longitudeKey = "longitude" - static let addressKey = "address" - // DEPRECATED - kept for migration reads only - static let anchorTypeKey = "anchorType" - static let anchorIdKey = "anchorId" - static let anchorDayKey = "anchorDay" - static let sortOrderKey = "sortOrder" - - 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.dayKey] = item.day - record[CKCustomItineraryItem.sortOrderDoubleKey] = item.sortOrder - record[CKCustomItineraryItem.createdAtKey] = item.createdAt - record[CKCustomItineraryItem.modifiedAtKey] = item.modifiedAt - // Location fields (nil values are not stored in CloudKit) - record[CKCustomItineraryItem.latitudeKey] = item.latitude - record[CKCustomItineraryItem.longitudeKey] = item.longitude - record[CKCustomItineraryItem.addressKey] = item.address - 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 createdAt = record[CKCustomItineraryItem.createdAtKey] as? Date, - let modifiedAt = record[CKCustomItineraryItem.modifiedAtKey] as? Date - else { return nil } - - // Read new fields, with migration fallback from old fields - let day: Int - if let newDay = record[CKCustomItineraryItem.dayKey] as? Int { - day = newDay - } else if let oldDay = record[CKCustomItineraryItem.anchorDayKey] as? Int { - // Migration: use old anchorDay - day = oldDay - } else { - return nil - } - - let sortOrder: Double - if let newSortOrder = record[CKCustomItineraryItem.sortOrderDoubleKey] as? Double { - sortOrder = newSortOrder - } else if let oldSortOrder = record[CKCustomItineraryItem.sortOrderKey] as? Int { - // Migration: convert old Int sortOrder to Double - sortOrder = Double(oldSortOrder) - } else { - sortOrder = 0.0 - } - - // Location fields (optional - nil if not stored) - let latitude = record[CKCustomItineraryItem.latitudeKey] as? Double - let longitude = record[CKCustomItineraryItem.longitudeKey] as? Double - let address = record[CKCustomItineraryItem.addressKey] as? String - - return CustomItineraryItem( - id: itemId, - tripId: tripId, - category: category, - title: title, - day: day, - sortOrder: sortOrder, - createdAt: createdAt, - modifiedAt: modifiedAt, - latitude: latitude, - longitude: longitude, - address: address - ) - } -} - -// MARK: - CKTravelDayOverride - -struct CKTravelDayOverride { - static let overrideIdKey = "overrideId" - static let tripIdKey = "tripId" - static let travelAnchorIdKey = "travelAnchorId" - static let displayDayKey = "displayDay" - static let createdAtKey = "createdAt" - static let modifiedAtKey = "modifiedAt" - - let record: CKRecord - - init(record: CKRecord) { - self.record = record - } - - init(override: TravelDayOverride) { - let record = CKRecord( - recordType: CKRecordType.travelDayOverride, - recordID: CKRecord.ID(recordName: override.id.uuidString) - ) - record[CKTravelDayOverride.overrideIdKey] = override.id.uuidString - record[CKTravelDayOverride.tripIdKey] = override.tripId.uuidString - record[CKTravelDayOverride.travelAnchorIdKey] = override.travelAnchorId - record[CKTravelDayOverride.displayDayKey] = override.displayDay - record[CKTravelDayOverride.createdAtKey] = override.createdAt - record[CKTravelDayOverride.modifiedAtKey] = override.modifiedAt - self.record = record - } - - func toOverride() -> TravelDayOverride? { - guard let overrideIdString = record[CKTravelDayOverride.overrideIdKey] as? String, - let overrideId = UUID(uuidString: overrideIdString), - let tripIdString = record[CKTravelDayOverride.tripIdKey] as? String, - let tripId = UUID(uuidString: tripIdString), - let travelAnchorId = record[CKTravelDayOverride.travelAnchorIdKey] as? String, - let displayDay = record[CKTravelDayOverride.displayDayKey] as? Int, - let createdAt = record[CKTravelDayOverride.createdAtKey] as? Date, - let modifiedAt = record[CKTravelDayOverride.modifiedAtKey] as? Date - else { return nil } - - return TravelDayOverride( - id: overrideId, - tripId: tripId, - travelAnchorId: travelAnchorId, - displayDay: displayDay, - createdAt: createdAt, - modifiedAt: modifiedAt - ) - } -} diff --git a/SportsTime/Core/Models/Local/SavedTrip.swift b/SportsTime/Core/Models/Local/SavedTrip.swift index 90d67fd..ba60acc 100644 --- a/SportsTime/Core/Models/Local/SavedTrip.swift +++ b/SportsTime/Core/Models/Local/SavedTrip.swift @@ -101,67 +101,62 @@ final class TripVote { } } -// MARK: - Local Custom Item (Cache) +// MARK: - Local Itinerary Item (Cache) @Model -final class LocalCustomItem { +final class LocalItineraryItem { @Attribute(.unique) var id: UUID var tripId: UUID - var category: String - var title: String var day: Int var sortOrder: Double - var createdAt: Date + var kindData: Data // Encoded ItineraryItem.Kind var modifiedAt: Date var pendingSync: Bool // True if needs to sync to CloudKit init( id: UUID = UUID(), tripId: UUID, - category: CustomItineraryItem.ItemCategory, - title: String, day: Int, sortOrder: Double = 0.0, - createdAt: Date = Date(), + kindData: Data, modifiedAt: Date = Date(), pendingSync: Bool = false ) { self.id = id self.tripId = tripId - self.category = category.rawValue - self.title = title self.day = day self.sortOrder = sortOrder - self.createdAt = createdAt + self.kindData = kindData self.modifiedAt = modifiedAt self.pendingSync = pendingSync } - var toItem: CustomItineraryItem? { - guard let category = CustomItineraryItem.ItemCategory(rawValue: category) + var toItem: ItineraryItem? { + let decoder = JSONDecoder() + guard let kind = try? decoder.decode(ItemKind.self, from: kindData) else { return nil } - return CustomItineraryItem( + return ItineraryItem( id: id, tripId: tripId, - category: category, - title: title, day: day, sortOrder: sortOrder, - createdAt: createdAt, + kind: kind, modifiedAt: modifiedAt ) } - static func from(_ item: CustomItineraryItem, pendingSync: Bool = false) -> LocalCustomItem { - LocalCustomItem( + static func from(_ item: ItineraryItem, pendingSync: Bool = false) -> LocalItineraryItem? { + let encoder = JSONEncoder() + guard let kindData = try? encoder.encode(item.kind) + else { return nil } + + return LocalItineraryItem( id: item.id, tripId: item.tripId, - category: item.category, - title: item.title, day: item.day, sortOrder: item.sortOrder, - createdAt: item.createdAt, + kindData: kindData, modifiedAt: item.modifiedAt, pendingSync: pendingSync ) diff --git a/SportsTime/Core/Services/AppDelegate.swift b/SportsTime/Core/Services/AppDelegate.swift index 134475d..71c9e0c 100644 --- a/SportsTime/Core/Services/AppDelegate.swift +++ b/SportsTime/Core/Services/AppDelegate.swift @@ -40,9 +40,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { // Handle CloudKit subscription notification - Task { - await CustomItemSubscriptionService.shared.handleRemoteNotification(userInfo: userInfo) - completionHandler(.newData) - } + // TODO: Re-implement subscription handling for ItineraryItem changes + completionHandler(.noData) } } diff --git a/SportsTime/Features/Trip/Views/AddItemSheet.swift b/SportsTime/Features/Trip/Views/AddItemSheet.swift index e1bc46e..deb5873 100644 --- a/SportsTime/Features/Trip/Views/AddItemSheet.swift +++ b/SportsTime/Features/Trip/Views/AddItemSheet.swift @@ -8,14 +8,43 @@ import SwiftUI import MapKit +/// Category for custom itinerary items with emoji icons +enum ItemCategory: String, CaseIterable { + case restaurant + case attraction + case fuel + case hotel + case other + + var icon: String { + switch self { + case .restaurant: return "\u{1F37D}" // 🍽️ + case .attraction: return "\u{1F3A2}" // 🎒 + case .fuel: return "\u{26FD}" // β›½ + case .hotel: return "\u{1F3E8}" // 🏨 + case .other: return "\u{1F4CC}" // πŸ“Œ + } + } + + var label: String { + switch self { + case .restaurant: return "Eat" + case .attraction: return "See" + case .fuel: return "Fuel" + case .hotel: return "Stay" + case .other: return "Other" + } + } +} + struct AddItemSheet: View { @Environment(\.dismiss) private var dismiss @Environment(\.colorScheme) private var colorScheme let tripId: UUID let day: Int - let existingItem: CustomItineraryItem? - var onSave: (CustomItineraryItem) -> Void + let existingItem: ItineraryItem? + var onSave: (ItineraryItem) -> Void // Entry mode enum EntryMode: String, CaseIterable { @@ -24,7 +53,7 @@ struct AddItemSheet: View { } @State private var entryMode: EntryMode = .searchPlaces - @State private var selectedCategory: CustomItineraryItem.ItemCategory = .restaurant + @State private var selectedCategory: ItemCategory = .restaurant @State private var title: String = "" @State private var isSaving = false @@ -80,9 +109,10 @@ struct AddItemSheet: View { } } .onAppear { - if let existing = existingItem { - selectedCategory = existing.category - title = existing.title + if let existing = existingItem, let info = existing.customInfo { + // Find matching category by icon, default to .other + selectedCategory = ItemCategory.allCases.first { $0.icon == info.icon } ?? .other + title = info.title // If editing a mappable item, switch to custom mode entryMode = .custom } @@ -231,7 +261,7 @@ struct AddItemSheet: View { @ViewBuilder private var categoryPicker: some View { HStack(spacing: Theme.Spacing.md) { - ForEach(CustomItineraryItem.ItemCategory.allCases, id: \.self) { category in + ForEach(ItemCategory.allCases, id: \.self) { category in CategoryButton( category: category, isSelected: selectedCategory == category @@ -245,54 +275,68 @@ struct AddItemSheet: View { private func saveItem() { isSaving = true - let item: CustomItineraryItem + let item: ItineraryItem - if let existing = existingItem { + if let existing = existingItem, let existingInfo = existing.customInfo { // Editing existing item - preserve day and sortOrder let trimmedTitle = title.trimmingCharacters(in: .whitespaces) guard !trimmedTitle.isEmpty else { return } - item = CustomItineraryItem( + let customInfo = CustomInfo( + title: trimmedTitle, + icon: selectedCategory.icon, + time: existingInfo.time, + latitude: existingInfo.latitude, + longitude: existingInfo.longitude, + address: existingInfo.address + ) + + item = ItineraryItem( id: existing.id, tripId: existing.tripId, - category: selectedCategory, - title: trimmedTitle, day: existing.day, sortOrder: existing.sortOrder, - createdAt: existing.createdAt, - modifiedAt: Date(), - latitude: existing.latitude, - longitude: existing.longitude, - address: existing.address + kind: .custom(customInfo), + modifiedAt: Date() ) } else if entryMode == .searchPlaces, let place = selectedPlace { // Creating from MapKit search let placeName = place.name ?? "Unknown Place" let coordinate = place.placemark.coordinate - // New items get sortOrder 0 (will be placed at beginning, caller can adjust) - item = CustomItineraryItem( - tripId: tripId, - category: selectedCategory, + let customInfo = CustomInfo( title: placeName, - day: day, - sortOrder: 0.0, + icon: selectedCategory.icon, + time: nil, latitude: coordinate.latitude, longitude: coordinate.longitude, address: formatAddress(for: place) ) + + // New items get sortOrder 0 (will be placed at beginning, caller can adjust) + item = ItineraryItem( + tripId: tripId, + day: day, + sortOrder: 0.0, + kind: .custom(customInfo) + ) } else { // Creating custom item (no location) let trimmedTitle = title.trimmingCharacters(in: .whitespaces) guard !trimmedTitle.isEmpty else { return } - // New items get sortOrder 0 (will be placed at beginning, caller can adjust) - item = CustomItineraryItem( - tripId: tripId, - category: selectedCategory, + let customInfo = CustomInfo( title: trimmedTitle, + icon: selectedCategory.icon, + time: nil + ) + + // New items get sortOrder 0 (will be placed at beginning, caller can adjust) + item = ItineraryItem( + tripId: tripId, day: day, - sortOrder: 0.0 + sortOrder: 0.0, + kind: .custom(customInfo) ) } @@ -353,7 +397,7 @@ private struct PlaceResultRow: View { // MARK: - Category Button private struct CategoryButton: View { - let category: CustomItineraryItem.ItemCategory + let category: ItemCategory let isSelected: Bool let action: () -> Void diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index 518a318..96a9785 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -151,11 +151,11 @@ // - Parameters: itemId, newDay (1-indexed), newSortOrder // - Parent updates item and syncs to CloudKit // -// onCustomItemTapped: ((CustomItineraryItem) -> Void)? +// onCustomItemTapped: ((ItineraryItem) -> Void)? // - Called when user taps custom item row // - Parent presents edit sheet // -// onCustomItemDeleted: ((CustomItineraryItem) -> Void)? +// onCustomItemDeleted: ((ItineraryItem) -> Void)? // - Called from context menu delete action // - Parent deletes from CloudKit // @@ -209,8 +209,8 @@ // // - ItineraryTableViewWrapper.swift: SwiftUI bridge, data transformation // - TripDetailView.swift: Parent view, owns state, handles callbacks -// - CustomItineraryItem.swift: Domain model with (day, sortOrder) positioning -// - CustomItemService.swift: CloudKit persistence for custom items +// - ItineraryItem.swift: Domain model with (day, sortOrder, kind) positioning +// - ItineraryItemService.swift: CloudKit persistence for itinerary items // // ═══════════════════════════════════════════════════════════════════════════════ @@ -250,7 +250,7 @@ enum ItineraryRowItem: Identifiable, Equatable { case dayHeader(dayNumber: Int, date: Date) // Fixed: structural anchor (includes Add button) case games([RichGame], dayNumber: Int) // Fixed: games are trip-determined case travel(TravelSegment, dayNumber: Int) // Reorderable: within valid range - case customItem(CustomItineraryItem) // Reorderable: anywhere + case customItem(ItineraryItem) // Reorderable: anywhere /// Stable identifier for table view diffing and external references. /// Travel IDs are lowercase to ensure consistency across sessions. @@ -297,8 +297,8 @@ final class ItineraryTableViewController: UITableViewController { // Callbacks var onTravelMoved: ((String, Int) -> Void)? // travelId, newDay var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder - var onCustomItemTapped: ((CustomItineraryItem) -> Void)? - var onCustomItemDeleted: ((CustomItineraryItem) -> Void)? + var onCustomItemTapped: ((ItineraryItem) -> Void)? + var onCustomItemDeleted: ((ItineraryItem) -> Void)? var onAddButtonTapped: ((Int) -> Void)? // Just day number // Cell reuse identifiers @@ -981,7 +981,7 @@ final class ItineraryTableViewController: UITableViewController { /// Custom item cell - shows user-added item with category icon. /// Selectable (opens edit sheet on tap) and draggable. - private func configureCustomItemCell(_ cell: UITableViewCell, item: CustomItineraryItem) { + private func configureCustomItemCell(_ cell: UITableViewCell, item: ItineraryItem) { cell.contentConfiguration = UIHostingConfiguration { CustomItemRowView(item: item, colorScheme: colorScheme) } @@ -1225,36 +1225,42 @@ struct TravelRowView: View { /// /// This row is both tappable (opens edit sheet) and draggable. struct CustomItemRowView: View { - let item: CustomItineraryItem + let item: ItineraryItem let colorScheme: ColorScheme + private var customInfo: CustomInfo? { + item.customInfo + } + var body: some View { HStack(spacing: Theme.Spacing.sm) { // Category icon (emoji) - Text(item.category.icon) - .font(.title3) + if let info = customInfo { + Text(info.icon) + .font(.title3) - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - Text(item.title) - .font(.subheadline) - .foregroundStyle(Theme.textPrimary(colorScheme)) - .lineLimit(1) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(info.title) + .font(.subheadline) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .lineLimit(1) - // Map pin indicator for items with coordinates - if item.isMappable { - Image(systemName: "mappin.circle.fill") - .font(.caption) - .foregroundStyle(Theme.warmOrange) + // Map pin indicator for items with coordinates + if info.isMappable { + Image(systemName: "mappin.circle.fill") + .font(.caption) + .foregroundStyle(Theme.warmOrange) + } } - } - // Address subtitle (shown only if present) - if let address = item.address, !address.isEmpty { - Text(address) - .font(.caption) - .foregroundStyle(Theme.textMuted(colorScheme)) - .lineLimit(1) + // Address subtitle (shown only if present) + if let address = info.address, !address.isEmpty { + Text(address) + .font(.caption) + .foregroundStyle(Theme.textMuted(colorScheme)) + .lineLimit(1) + } } } diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift index d4d0645..079cab5 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift @@ -12,32 +12,32 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent let trip: Trip let games: [RichGame] - let customItems: [CustomItineraryItem] + let itineraryItems: [ItineraryItem] let travelDayOverrides: [String: Int] let headerContent: HeaderContent // Callbacks var onTravelMoved: ((String, Int) -> Void)? var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder - var onCustomItemTapped: ((CustomItineraryItem) -> Void)? - var onCustomItemDeleted: ((CustomItineraryItem) -> Void)? + var onCustomItemTapped: ((ItineraryItem) -> Void)? + var onCustomItemDeleted: ((ItineraryItem) -> Void)? var onAddButtonTapped: ((Int) -> Void)? // Just day number init( trip: Trip, games: [RichGame], - customItems: [CustomItineraryItem], + itineraryItems: [ItineraryItem], travelDayOverrides: [String: Int], @ViewBuilder headerContent: () -> HeaderContent, onTravelMoved: ((String, Int) -> Void)? = nil, onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil, - onCustomItemTapped: ((CustomItineraryItem) -> Void)? = nil, - onCustomItemDeleted: ((CustomItineraryItem) -> Void)? = nil, + onCustomItemTapped: ((ItineraryItem) -> Void)? = nil, + onCustomItemDeleted: ((ItineraryItem) -> Void)? = nil, onAddButtonTapped: ((Int) -> Void)? = nil ) { self.trip = trip self.games = games - self.customItems = customItems + self.itineraryItems = itineraryItems self.travelDayOverrides = travelDayOverrides self.headerContent = headerContent() self.onTravelMoved = onTravelMoved @@ -159,12 +159,13 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent // Travel before this day (travel is stored on the destination day) let travelBefore: TravelSegment? = travelByDay[dayNum] - // Custom items for this day - simply filter by day and sort by sortOrder + // Custom items for this day - filter by day and custom kind, sort by sortOrder // Note: Add button is now embedded in the day header row (not a separate item) - let dayItems = customItems.filter { $0.day == dayNum } + let customItemsForDay = itineraryItems + .filter { $0.day == dayNum && $0.isCustom } .sorted { $0.sortOrder < $1.sortOrder } - for item in dayItems { + for item in customItemsForDay { items.append(ItineraryRowItem.customItem(item)) } diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 8bc8fff..f4b5f6f 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -34,12 +34,12 @@ struct TripDetailView: View { @State private var loadedGames: [String: RichGame] = [:] @State private var isLoadingGames = false - // Custom items state - @State private var customItems: [CustomItineraryItem] = [] + // Itinerary items state + @State private var itineraryItems: [ItineraryItem] = [] @State private var addItemAnchor: AddItemAnchor? - @State private var editingItem: CustomItineraryItem? + @State private var editingItem: ItineraryItem? @State private var subscriptionCancellable: AnyCancellable? - @State private var draggedItem: CustomItineraryItem? + @State private var draggedItem: ItineraryItem? @State private var draggedTravelId: String? // Track which travel segment is being dragged @State private var dropTargetId: String? // Track which drop zone is being hovered @State private var travelDayOverrides: [String: Int] = [:] // Key: travel ID, Value: day number @@ -122,7 +122,7 @@ struct TripDetailView: View { day: anchor.day, existingItem: nil ) { item in - Task { await saveCustomItem(item) } + Task { await saveItineraryItem(item) } } } .sheet(item: $editingItem) { item in @@ -131,7 +131,7 @@ struct TripDetailView: View { day: item.day, existingItem: item ) { updatedItem in - Task { await saveCustomItem(updatedItem) } + Task { await saveItineraryItem(updatedItem) } } } .onAppear { @@ -140,21 +140,23 @@ struct TripDetailView: View { .task { await loadGamesIfNeeded() if allowCustomItems { - await loadCustomItems() + await loadItineraryItems() await setupSubscription() } } .onDisappear { subscriptionCancellable?.cancel() } - .onChange(of: customItems) { _, newItems in + .onChange(of: itineraryItems) { _, newItems in // Clear drag state after items update (move completed) draggedItem = nil dropTargetId = nil // Recalculate routes when custom items change (mappable items affect route) - print("πŸ—ΊοΈ [MapUpdate] customItems changed, count: \(newItems.count)") - for item in newItems where item.isMappable { - print("πŸ—ΊοΈ [MapUpdate] Mappable: \(item.title) on day \(item.day), sortOrder: \(item.sortOrder)") + print("πŸ—ΊοΈ [MapUpdate] itineraryItems changed, count: \(newItems.count)") + for item in newItems { + if item.isCustom, let info = item.customInfo, info.isMappable { + print("πŸ—ΊοΈ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)") + } } Task { updateMapRegion() @@ -182,7 +184,7 @@ struct TripDetailView: View { ItineraryTableViewWrapper( trip: trip, games: Array(games.values), - customItems: customItems, + itineraryItems: itineraryItems, travelDayOverrides: travelDayOverrides, headerContent: { VStack(spacing: 0) { @@ -222,7 +224,7 @@ struct TripDetailView: View { }, onCustomItemMoved: { itemId, day, sortOrder in Task { @MainActor in - guard let item = customItems.first(where: { $0.id == itemId }) else { return } + guard let item = itineraryItems.first(where: { $0.id == itemId }) else { return } await moveItem(item, toDay: day, sortOrder: sortOrder) } }, @@ -230,7 +232,7 @@ struct TripDetailView: View { editingItem = item }, onCustomItemDeleted: { item in - Task { await deleteCustomItem(item) } + Task { await deleteItineraryItem(item) } }, onAddButtonTapped: { day in addItemAnchor = AddItemAnchor(day: day) @@ -570,9 +572,15 @@ struct TripDetailView: View { let isDraggingThis = draggedItem?.id == item.id CustomItemRow( item: item, - onTap: { editingItem = item }, - onDelete: { Task { await deleteCustomItem(item) } } + onTap: { editingItem = item } ) + .contextMenu { + Button(role: .destructive) { + Task { await deleteItineraryItem(item) } + } label: { + Label("Delete", systemImage: "trash") + } + } .opacity(isDraggingThis ? 0.4 : 1.0) .staggeredAnimation(index: index) .overlay(alignment: .top) { @@ -644,12 +652,12 @@ struct TripDetailView: View { provider.loadObject(ofClass: NSString.self) { item, _ in guard let droppedId = item as? String, let itemId = UUID(uuidString: droppedId), - let droppedItem = self.customItems.first(where: { $0.id == itemId }) else { return } + let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return } Task { @MainActor in let day = self.findDayForTravelSegment(segment) // Place at beginning of day (sortOrder before existing items) - let minSortOrder = self.customItems + let minSortOrder = self.itineraryItems .filter { $0.day == day && $0.id != droppedItem.id } .map { $0.sortOrder } .min() ?? 1.0 @@ -659,7 +667,7 @@ struct TripDetailView: View { return true } - private func handleCustomItemDrop(providers: [NSItemProvider], targetItem: CustomItineraryItem) -> Bool { + private func handleCustomItemDrop(providers: [NSItemProvider], targetItem: ItineraryItem) -> Bool { guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else { return false } @@ -672,12 +680,12 @@ struct TripDetailView: View { provider.loadObject(ofClass: NSString.self) { item, _ in guard let droppedId = item as? String, let itemId = UUID(uuidString: droppedId), - let droppedItem = self.customItems.first(where: { $0.id == itemId }), + let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }), droppedItem.id != targetItem.id else { return } Task { @MainActor in // Place before target item using midpoint insertion - let itemsInDay = self.customItems.filter { $0.day == targetItem.day && $0.id != droppedItem.id } + let itemsInDay = self.itineraryItems.filter { $0.day == targetItem.day && $0.id != droppedItem.id } .sorted { $0.sortOrder < $1.sortOrder } let targetIdx = itemsInDay.firstIndex(where: { $0.id == targetItem.id }) ?? 0 let prevSortOrder = targetIdx > 0 ? itemsInDay[targetIdx - 1].sortOrder : 0.0 @@ -701,11 +709,11 @@ struct TripDetailView: View { provider.loadObject(ofClass: NSString.self) { item, _ in guard let droppedId = item as? String, let itemId = UUID(uuidString: droppedId), - let droppedItem = self.customItems.first(where: { $0.id == itemId }) else { return } + let droppedItem = self.itineraryItems.first(where: { $0.id == itemId }) else { return } Task { @MainActor in // Calculate sortOrder: append at end of day's items - let maxSortOrder = self.customItems + let maxSortOrder = self.itineraryItems .filter { $0.day == day && $0.id != droppedItem.id } .map { $0.sortOrder } .max() ?? 0.0 @@ -759,26 +767,23 @@ struct TripDetailView: View { } /// Move item to a new day and sortOrder position - private func moveItem(_ item: CustomItineraryItem, toDay day: Int, sortOrder: Double) async { + private func moveItem(_ item: ItineraryItem, toDay day: Int, sortOrder: Double) async { var updated = item updated.day = day updated.sortOrder = sortOrder updated.modifiedAt = Date() - print("πŸ“ [Move] Moving \(item.title) to day \(day), sortOrder: \(sortOrder)") + let title = item.customInfo?.title ?? "item" + print("πŸ“ [Move] Moving \(title) to day \(day), sortOrder: \(sortOrder)") // Update local state - if let idx = customItems.firstIndex(where: { $0.id == item.id }) { - customItems[idx] = updated + if let idx = itineraryItems.firstIndex(where: { $0.id == item.id }) { + itineraryItems[idx] = updated } - // Sync to CloudKit - do { - _ = try await CustomItemService.shared.updateItem(updated) - print("βœ… [Move] Synced \(updated.title) with day: \(day), sortOrder: \(sortOrder)") - } catch { - print("❌ [Move] Failed to sync: \(error)") - } + // Sync to CloudKit (debounced) + await ItineraryItemService.shared.updateItem(updated) + print("βœ… [Move] Synced \(title) with day: \(day), sortOrder: \(sortOrder)") } /// Build itinerary sections: shows ALL days with travel, custom items, and add buttons @@ -840,7 +845,8 @@ struct TripDetailView: View { // Add button first - always right after day header sections.append(.addButton(day: dayNum)) - let dayItems = customItems.filter { $0.day == dayNum } + let dayItems = itineraryItems + .filter { $0.day == dayNum && $0.isCustom } .sorted { $0.sortOrder < $1.sortOrder } for item in dayItems { sections.append(.customItem(item)) @@ -1021,8 +1027,8 @@ struct TripDetailView: View { } /// Mappable custom items for display on the map - private var mappableCustomItems: [CustomItineraryItem] { - customItems.filter { $0.isMappable } + private var mappableCustomItems: [ItineraryItem] { + itineraryItems.filter { $0.isCustom && $0.customInfo?.isMappable == true } } /// Convert stored route coordinates to MKPolyline for rendering @@ -1041,7 +1047,8 @@ struct TripDetailView: View { print("πŸ—ΊοΈ [Waypoints] Building waypoints. Mappable items by day:") for (day, items) in itemsByDay.sorted(by: { $0.key < $1.key }) { for item in items.sorted(by: { $0.sortOrder < $1.sortOrder }) { - print("πŸ—ΊοΈ [Waypoints] Day \(day): \(item.title), sortOrder: \(item.sortOrder)") + let title = item.customInfo?.title ?? "item" + print("πŸ—ΊοΈ [Waypoints] Day \(day): \(title), sortOrder: \(item.sortOrder)") } } @@ -1101,9 +1108,9 @@ struct TripDetailView: View { if let items = itemsByDay[dayNumber] { let sortedItems = items.sorted { $0.sortOrder < $1.sortOrder } for item in sortedItems { - if let coord = item.coordinate { - print("πŸ—ΊοΈ [Waypoints] Adding \(item.title) (sortOrder: \(item.sortOrder))") - waypoints.append((item.title, coord, true)) + if let info = item.customInfo, let coord = info.coordinate { + print("πŸ—ΊοΈ [Waypoints] Adding \(info.title) (sortOrder: \(item.sortOrder))") + waypoints.append((info.title, coord, true)) } } } @@ -1253,86 +1260,81 @@ struct TripDetailView: View { } } - // MARK: - Custom Items (CloudKit persistence) + // MARK: - Itinerary Items (CloudKit persistence) private func setupSubscription() async { - // Subscribe to real-time updates for this trip - do { - try await CustomItemSubscriptionService.shared.subscribeToTrip(trip.id) + // TODO: Re-implement CloudKit subscription for ItineraryItem changes + // The subscription service was removed during the ItineraryItem refactor. + // For now, items are only loaded on view appear. + print("πŸ“‘ [Subscription] CloudKit subscriptions not yet implemented for ItineraryItem") + } - // Listen for changes and reload - subscriptionCancellable = await CustomItemSubscriptionService.shared.changePublisher - .filter { $0 == self.trip.id } - .receive(on: DispatchQueue.main) - .sink { [self] _ in - print("πŸ“‘ [Subscription] Received update, reloading custom items...") - Task { - await loadCustomItems() - } + private func loadItineraryItems() async { + print("πŸ” [ItineraryItems] Loading items for trip: \(trip.id)") + do { + let items = try await ItineraryItemService.shared.fetchItems(forTripId: trip.id) + print("βœ… [ItineraryItems] Loaded \(items.count) items from CloudKit") + itineraryItems = items + + // Extract travel day overrides from travel-type items + var overrides: [String: Int] = [:] + for item in items where item.isTravel { + if let travelInfo = item.travelInfo { + let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())" + overrides[travelId] = item.day } - } catch { - print("πŸ“‘ [Subscription] Failed to subscribe: \(error)") - } - } - - private func loadCustomItems() async { - print("πŸ” [CustomItems] Loading items for trip: \(trip.id)") - do { - let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id) - print("βœ… [CustomItems] Loaded \(items.count) items from CloudKit") - customItems = items - - // Also load travel day overrides - let overrides = try await TravelOverrideService.shared.fetchOverridesAsDictionary(forTripId: trip.id) - print("βœ… [TravelOverrides] Loaded \(overrides.count) travel day overrides") + } travelDayOverrides = overrides + print("βœ… [TravelOverrides] Extracted \(overrides.count) travel day overrides") } catch { - print("❌ [CustomItems] Failed to load: \(error)") + print("❌ [ItineraryItems] Failed to load: \(error)") } } - private func saveCustomItem(_ item: CustomItineraryItem) async { + private func saveItineraryItem(_ item: ItineraryItem) async { // Check if this is an update or create - let isUpdate = customItems.contains(where: { $0.id == item.id }) - print("πŸ’Ύ [CustomItems] Saving item: '\(item.title)' (isUpdate: \(isUpdate))") + let isUpdate = itineraryItems.contains(where: { $0.id == item.id }) + let title = item.customInfo?.title ?? "item" + print("πŸ’Ύ [ItineraryItems] Saving item: '\(title)' (isUpdate: \(isUpdate))") print(" - tripId: \(item.tripId)") print(" - day: \(item.day), sortOrder: \(item.sortOrder)") // Update local state immediately for responsive UI if isUpdate { - if let index = customItems.firstIndex(where: { $0.id == item.id }) { - customItems[index] = item + if let index = itineraryItems.firstIndex(where: { $0.id == item.id }) { + itineraryItems[index] = item } } else { - customItems.append(item) + itineraryItems.append(item) } // Persist to CloudKit do { if isUpdate { - let updated = try await CustomItemService.shared.updateItem(item) - print("βœ… [CustomItems] Updated in CloudKit: \(updated.title)") + await ItineraryItemService.shared.updateItem(item) + print("βœ… [ItineraryItems] Updated in CloudKit: \(title)") } else { - let created = try await CustomItemService.shared.createItem(item) - print("βœ… [CustomItems] Created in CloudKit: \(created.title)") + _ = try await ItineraryItemService.shared.createItem(item) + print("βœ… [ItineraryItems] Created in CloudKit: \(title)") } } catch { - print("❌ [CustomItems] CloudKit save failed: \(error)") + print("❌ [ItineraryItems] CloudKit save failed: \(error)") } } - private func deleteCustomItem(_ item: CustomItineraryItem) async { - print("πŸ—‘οΈ [CustomItems] Deleting item: '\(item.title)'") + private func deleteItineraryItem(_ item: ItineraryItem) async { + let title = item.customInfo?.title ?? "item" + print("πŸ—‘οΈ [ItineraryItems] Deleting item: '\(title)'") // Remove from local state immediately - customItems.removeAll { $0.id == item.id } + itineraryItems.removeAll { $0.id == item.id } // Delete from CloudKit do { - try await CustomItemService.shared.deleteItem(item.id) - print("βœ… [CustomItems] Deleted from CloudKit") + try await ItineraryItemService.shared.deleteItem(item.id) + print("βœ… [ItineraryItems] Deleted from CloudKit") } catch { - print("❌ [CustomItems] CloudKit delete failed: \(error)") + print("❌ [ItineraryItems] CloudKit delete failed: \(error)") } } @@ -1375,10 +1377,10 @@ struct TripDetailView: View { // Otherwise, it's a custom item drop guard let itemId = UUID(uuidString: droppedId), - let item = self.customItems.first(where: { $0.id == itemId }) else { return } + let item = self.itineraryItems.first(where: { $0.id == itemId }) else { return } // Append at end of day's items - let maxSortOrder = self.customItems + let maxSortOrder = self.itineraryItems .filter { $0.day == dayNumber && $0.id != item.id } .map { $0.sortOrder } .max() ?? 0.0 @@ -1393,18 +1395,45 @@ struct TripDetailView: View { private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int) async { print("πŸ’Ύ [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay)") - let override = TravelDayOverride( - tripId: trip.id, - travelAnchorId: travelAnchorId, - displayDay: displayDay - ) - - do { - _ = try await TravelOverrideService.shared.saveOverride(override) - print("βœ… [TravelOverrides] Saved to CloudKit") - } catch { - print("❌ [TravelOverrides] CloudKit save failed: \(error)") + // Parse travel ID to extract cities (format: "travel:city1->city2") + let stripped = travelAnchorId.replacingOccurrences(of: "travel:", with: "") + let parts = stripped.components(separatedBy: "->") + guard parts.count == 2 else { + print("❌ [TravelOverrides] Invalid travel ID format: \(travelAnchorId)") + return } + + let fromCity = parts[0] + let toCity = parts[1] + + // Find existing travel item or create new one + if let existingIndex = itineraryItems.firstIndex(where: { + $0.isTravel && $0.travelInfo?.fromCity.lowercased() == fromCity && $0.travelInfo?.toCity.lowercased() == toCity + }) { + // Update existing + var updated = itineraryItems[existingIndex] + updated.day = displayDay + updated.modifiedAt = Date() + itineraryItems[existingIndex] = updated + await ItineraryItemService.shared.updateItem(updated) + } else { + // Create new travel item + let travelInfo = TravelInfo(fromCity: fromCity, toCity: toCity) + let item = ItineraryItem( + tripId: trip.id, + day: displayDay, + sortOrder: 0, // Travel always comes first in day + kind: .travel(travelInfo) + ) + itineraryItems.append(item) + do { + _ = try await ItineraryItemService.shared.createItem(item) + } catch { + print("❌ [TravelOverrides] CloudKit save failed: \(error)") + return + } + } + print("βœ… [TravelOverrides] Saved to CloudKit") } } @@ -1413,7 +1442,7 @@ struct TripDetailView: View { enum ItinerarySection { case day(dayNumber: Int, date: Date, games: [RichGame]) case travel(TravelSegment) - case customItem(CustomItineraryItem) + case customItem(ItineraryItem) case addButton(day: Int) var isCustomItem: Bool { @@ -1807,7 +1836,7 @@ struct TripMapView: View { @Binding var cameraPosition: MapCameraPosition let routeCoordinates: [[CLLocationCoordinate2D]] let stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)] - let customItems: [CustomItineraryItem] + let customItems: [ItineraryItem] let colorScheme: ColorScheme let routeVersion: UUID // Force re-render when routes change @@ -1841,15 +1870,14 @@ struct TripMapView: View { // Custom item markers ForEach(customItems, id: \.id) { item in - if let coordinate = item.coordinate { - Annotation(item.title, coordinate: coordinate) { + if let info = item.customInfo, let coordinate = info.coordinate { + Annotation(info.title, coordinate: coordinate) { ZStack { Circle() .fill(Theme.warmOrange) .frame(width: 24, height: 24) - Image(systemName: item.category.systemImage) + Text(info.icon) .font(.caption2) - .foregroundStyle(.white) } } }