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:
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user