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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,32 +12,32 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: 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<HeaderContent: View>: 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))
}

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