refactor(itinerary): replace CustomItineraryItem with ItineraryItem across codebase

- Update CKModels.swift to remove deleted type references
- Migrate LocalCustomItem to LocalItineraryItem in SavedTrip.swift
- Update AppDelegate to handle subscription removal
- Refactor AddItemSheet to create ItineraryItem with CustomInfo
- Update ItineraryTableViewController and Wrapper for new model
- Refactor TripDetailView state, methods and callbacks
- Fix TripMapView to display custom items with new model structure

This completes the migration from the legacy CustomItineraryItem/TravelDayOverride
model to the unified ItineraryItem model with ItemKind enum.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-17 21:51:32 -06:00
parent b008af1c71
commit cd00384010
7 changed files with 273 additions and 354 deletions

View File

@@ -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)
}
}
}