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 stadiumAlias = "StadiumAlias"
|
||||||
static let tripPoll = "TripPoll"
|
static let tripPoll = "TripPoll"
|
||||||
static let pollVote = "PollVote"
|
static let pollVote = "PollVote"
|
||||||
static let customItineraryItem = "CustomItineraryItem"
|
static let itineraryItem = "ItineraryItem"
|
||||||
static let travelDayOverride = "TravelDayOverride"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CKTeam
|
// 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
|
@Model
|
||||||
final class LocalCustomItem {
|
final class LocalItineraryItem {
|
||||||
@Attribute(.unique) var id: UUID
|
@Attribute(.unique) var id: UUID
|
||||||
var tripId: UUID
|
var tripId: UUID
|
||||||
var category: String
|
|
||||||
var title: String
|
|
||||||
var day: Int
|
var day: Int
|
||||||
var sortOrder: Double
|
var sortOrder: Double
|
||||||
var createdAt: Date
|
var kindData: Data // Encoded ItineraryItem.Kind
|
||||||
var modifiedAt: Date
|
var modifiedAt: Date
|
||||||
var pendingSync: Bool // True if needs to sync to CloudKit
|
var pendingSync: Bool // True if needs to sync to CloudKit
|
||||||
|
|
||||||
init(
|
init(
|
||||||
id: UUID = UUID(),
|
id: UUID = UUID(),
|
||||||
tripId: UUID,
|
tripId: UUID,
|
||||||
category: CustomItineraryItem.ItemCategory,
|
|
||||||
title: String,
|
|
||||||
day: Int,
|
day: Int,
|
||||||
sortOrder: Double = 0.0,
|
sortOrder: Double = 0.0,
|
||||||
createdAt: Date = Date(),
|
kindData: Data,
|
||||||
modifiedAt: Date = Date(),
|
modifiedAt: Date = Date(),
|
||||||
pendingSync: Bool = false
|
pendingSync: Bool = false
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.tripId = tripId
|
self.tripId = tripId
|
||||||
self.category = category.rawValue
|
|
||||||
self.title = title
|
|
||||||
self.day = day
|
self.day = day
|
||||||
self.sortOrder = sortOrder
|
self.sortOrder = sortOrder
|
||||||
self.createdAt = createdAt
|
self.kindData = kindData
|
||||||
self.modifiedAt = modifiedAt
|
self.modifiedAt = modifiedAt
|
||||||
self.pendingSync = pendingSync
|
self.pendingSync = pendingSync
|
||||||
}
|
}
|
||||||
|
|
||||||
var toItem: CustomItineraryItem? {
|
var toItem: ItineraryItem? {
|
||||||
guard let category = CustomItineraryItem.ItemCategory(rawValue: category)
|
let decoder = JSONDecoder()
|
||||||
|
guard let kind = try? decoder.decode(ItemKind.self, from: kindData)
|
||||||
else { return nil }
|
else { return nil }
|
||||||
|
|
||||||
return CustomItineraryItem(
|
return ItineraryItem(
|
||||||
id: id,
|
id: id,
|
||||||
tripId: tripId,
|
tripId: tripId,
|
||||||
category: category,
|
|
||||||
title: title,
|
|
||||||
day: day,
|
day: day,
|
||||||
sortOrder: sortOrder,
|
sortOrder: sortOrder,
|
||||||
createdAt: createdAt,
|
kind: kind,
|
||||||
modifiedAt: modifiedAt
|
modifiedAt: modifiedAt
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func from(_ item: CustomItineraryItem, pendingSync: Bool = false) -> LocalCustomItem {
|
static func from(_ item: ItineraryItem, pendingSync: Bool = false) -> LocalItineraryItem? {
|
||||||
LocalCustomItem(
|
let encoder = JSONEncoder()
|
||||||
|
guard let kindData = try? encoder.encode(item.kind)
|
||||||
|
else { return nil }
|
||||||
|
|
||||||
|
return LocalItineraryItem(
|
||||||
id: item.id,
|
id: item.id,
|
||||||
tripId: item.tripId,
|
tripId: item.tripId,
|
||||||
category: item.category,
|
|
||||||
title: item.title,
|
|
||||||
day: item.day,
|
day: item.day,
|
||||||
sortOrder: item.sortOrder,
|
sortOrder: item.sortOrder,
|
||||||
createdAt: item.createdAt,
|
kindData: kindData,
|
||||||
modifiedAt: item.modifiedAt,
|
modifiedAt: item.modifiedAt,
|
||||||
pendingSync: pendingSync
|
pendingSync: pendingSync
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
||||||
) {
|
) {
|
||||||
// Handle CloudKit subscription notification
|
// Handle CloudKit subscription notification
|
||||||
Task {
|
// TODO: Re-implement subscription handling for ItineraryItem changes
|
||||||
await CustomItemSubscriptionService.shared.handleRemoteNotification(userInfo: userInfo)
|
completionHandler(.noData)
|
||||||
completionHandler(.newData)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,43 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import MapKit
|
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 {
|
struct AddItemSheet: View {
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@Environment(\.colorScheme) private var colorScheme
|
@Environment(\.colorScheme) private var colorScheme
|
||||||
|
|
||||||
let tripId: UUID
|
let tripId: UUID
|
||||||
let day: Int
|
let day: Int
|
||||||
let existingItem: CustomItineraryItem?
|
let existingItem: ItineraryItem?
|
||||||
var onSave: (CustomItineraryItem) -> Void
|
var onSave: (ItineraryItem) -> Void
|
||||||
|
|
||||||
// Entry mode
|
// Entry mode
|
||||||
enum EntryMode: String, CaseIterable {
|
enum EntryMode: String, CaseIterable {
|
||||||
@@ -24,7 +53,7 @@ struct AddItemSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@State private var entryMode: EntryMode = .searchPlaces
|
@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 title: String = ""
|
||||||
@State private var isSaving = false
|
@State private var isSaving = false
|
||||||
|
|
||||||
@@ -80,9 +109,10 @@ struct AddItemSheet: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
if let existing = existingItem {
|
if let existing = existingItem, let info = existing.customInfo {
|
||||||
selectedCategory = existing.category
|
// Find matching category by icon, default to .other
|
||||||
title = existing.title
|
selectedCategory = ItemCategory.allCases.first { $0.icon == info.icon } ?? .other
|
||||||
|
title = info.title
|
||||||
// If editing a mappable item, switch to custom mode
|
// If editing a mappable item, switch to custom mode
|
||||||
entryMode = .custom
|
entryMode = .custom
|
||||||
}
|
}
|
||||||
@@ -231,7 +261,7 @@ struct AddItemSheet: View {
|
|||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var categoryPicker: some View {
|
private var categoryPicker: some View {
|
||||||
HStack(spacing: Theme.Spacing.md) {
|
HStack(spacing: Theme.Spacing.md) {
|
||||||
ForEach(CustomItineraryItem.ItemCategory.allCases, id: \.self) { category in
|
ForEach(ItemCategory.allCases, id: \.self) { category in
|
||||||
CategoryButton(
|
CategoryButton(
|
||||||
category: category,
|
category: category,
|
||||||
isSelected: selectedCategory == category
|
isSelected: selectedCategory == category
|
||||||
@@ -245,54 +275,68 @@ struct AddItemSheet: View {
|
|||||||
private func saveItem() {
|
private func saveItem() {
|
||||||
isSaving = true
|
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
|
// Editing existing item - preserve day and sortOrder
|
||||||
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
||||||
guard !trimmedTitle.isEmpty else { return }
|
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,
|
id: existing.id,
|
||||||
tripId: existing.tripId,
|
tripId: existing.tripId,
|
||||||
category: selectedCategory,
|
|
||||||
title: trimmedTitle,
|
|
||||||
day: existing.day,
|
day: existing.day,
|
||||||
sortOrder: existing.sortOrder,
|
sortOrder: existing.sortOrder,
|
||||||
createdAt: existing.createdAt,
|
kind: .custom(customInfo),
|
||||||
modifiedAt: Date(),
|
modifiedAt: Date()
|
||||||
latitude: existing.latitude,
|
|
||||||
longitude: existing.longitude,
|
|
||||||
address: existing.address
|
|
||||||
)
|
)
|
||||||
} else if entryMode == .searchPlaces, let place = selectedPlace {
|
} else if entryMode == .searchPlaces, let place = selectedPlace {
|
||||||
// Creating from MapKit search
|
// Creating from MapKit search
|
||||||
let placeName = place.name ?? "Unknown Place"
|
let placeName = place.name ?? "Unknown Place"
|
||||||
let coordinate = place.placemark.coordinate
|
let coordinate = place.placemark.coordinate
|
||||||
|
|
||||||
// New items get sortOrder 0 (will be placed at beginning, caller can adjust)
|
let customInfo = CustomInfo(
|
||||||
item = CustomItineraryItem(
|
|
||||||
tripId: tripId,
|
|
||||||
category: selectedCategory,
|
|
||||||
title: placeName,
|
title: placeName,
|
||||||
day: day,
|
icon: selectedCategory.icon,
|
||||||
sortOrder: 0.0,
|
time: nil,
|
||||||
latitude: coordinate.latitude,
|
latitude: coordinate.latitude,
|
||||||
longitude: coordinate.longitude,
|
longitude: coordinate.longitude,
|
||||||
address: formatAddress(for: place)
|
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 {
|
} else {
|
||||||
// Creating custom item (no location)
|
// Creating custom item (no location)
|
||||||
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
let trimmedTitle = title.trimmingCharacters(in: .whitespaces)
|
||||||
guard !trimmedTitle.isEmpty else { return }
|
guard !trimmedTitle.isEmpty else { return }
|
||||||
|
|
||||||
// New items get sortOrder 0 (will be placed at beginning, caller can adjust)
|
let customInfo = CustomInfo(
|
||||||
item = CustomItineraryItem(
|
|
||||||
tripId: tripId,
|
|
||||||
category: selectedCategory,
|
|
||||||
title: trimmedTitle,
|
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,
|
day: day,
|
||||||
sortOrder: 0.0
|
sortOrder: 0.0,
|
||||||
|
kind: .custom(customInfo)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -353,7 +397,7 @@ private struct PlaceResultRow: View {
|
|||||||
// MARK: - Category Button
|
// MARK: - Category Button
|
||||||
|
|
||||||
private struct CategoryButton: View {
|
private struct CategoryButton: View {
|
||||||
let category: CustomItineraryItem.ItemCategory
|
let category: ItemCategory
|
||||||
let isSelected: Bool
|
let isSelected: Bool
|
||||||
let action: () -> Void
|
let action: () -> Void
|
||||||
|
|
||||||
|
|||||||
@@ -151,11 +151,11 @@
|
|||||||
// - Parameters: itemId, newDay (1-indexed), newSortOrder
|
// - Parameters: itemId, newDay (1-indexed), newSortOrder
|
||||||
// - Parent updates item and syncs to CloudKit
|
// - Parent updates item and syncs to CloudKit
|
||||||
//
|
//
|
||||||
// onCustomItemTapped: ((CustomItineraryItem) -> Void)?
|
// onCustomItemTapped: ((ItineraryItem) -> Void)?
|
||||||
// - Called when user taps custom item row
|
// - Called when user taps custom item row
|
||||||
// - Parent presents edit sheet
|
// - Parent presents edit sheet
|
||||||
//
|
//
|
||||||
// onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
|
// onCustomItemDeleted: ((ItineraryItem) -> Void)?
|
||||||
// - Called from context menu delete action
|
// - Called from context menu delete action
|
||||||
// - Parent deletes from CloudKit
|
// - Parent deletes from CloudKit
|
||||||
//
|
//
|
||||||
@@ -209,8 +209,8 @@
|
|||||||
//
|
//
|
||||||
// - ItineraryTableViewWrapper.swift: SwiftUI bridge, data transformation
|
// - ItineraryTableViewWrapper.swift: SwiftUI bridge, data transformation
|
||||||
// - TripDetailView.swift: Parent view, owns state, handles callbacks
|
// - TripDetailView.swift: Parent view, owns state, handles callbacks
|
||||||
// - CustomItineraryItem.swift: Domain model with (day, sortOrder) positioning
|
// - ItineraryItem.swift: Domain model with (day, sortOrder, kind) positioning
|
||||||
// - CustomItemService.swift: CloudKit persistence for custom items
|
// - 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 dayHeader(dayNumber: Int, date: Date) // Fixed: structural anchor (includes Add button)
|
||||||
case games([RichGame], dayNumber: Int) // Fixed: games are trip-determined
|
case games([RichGame], dayNumber: Int) // Fixed: games are trip-determined
|
||||||
case travel(TravelSegment, dayNumber: Int) // Reorderable: within valid range
|
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.
|
/// Stable identifier for table view diffing and external references.
|
||||||
/// Travel IDs are lowercase to ensure consistency across sessions.
|
/// Travel IDs are lowercase to ensure consistency across sessions.
|
||||||
@@ -297,8 +297,8 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
// Callbacks
|
// Callbacks
|
||||||
var onTravelMoved: ((String, Int) -> Void)? // travelId, newDay
|
var onTravelMoved: ((String, Int) -> Void)? // travelId, newDay
|
||||||
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
||||||
var onCustomItemTapped: ((CustomItineraryItem) -> Void)?
|
var onCustomItemTapped: ((ItineraryItem) -> Void)?
|
||||||
var onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
|
var onCustomItemDeleted: ((ItineraryItem) -> Void)?
|
||||||
var onAddButtonTapped: ((Int) -> Void)? // Just day number
|
var onAddButtonTapped: ((Int) -> Void)? // Just day number
|
||||||
|
|
||||||
// Cell reuse identifiers
|
// Cell reuse identifiers
|
||||||
@@ -981,7 +981,7 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
|
|
||||||
/// Custom item cell - shows user-added item with category icon.
|
/// Custom item cell - shows user-added item with category icon.
|
||||||
/// Selectable (opens edit sheet on tap) and draggable.
|
/// 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 {
|
cell.contentConfiguration = UIHostingConfiguration {
|
||||||
CustomItemRowView(item: item, colorScheme: colorScheme)
|
CustomItemRowView(item: item, colorScheme: colorScheme)
|
||||||
}
|
}
|
||||||
@@ -1225,36 +1225,42 @@ struct TravelRowView: View {
|
|||||||
///
|
///
|
||||||
/// This row is both tappable (opens edit sheet) and draggable.
|
/// This row is both tappable (opens edit sheet) and draggable.
|
||||||
struct CustomItemRowView: View {
|
struct CustomItemRowView: View {
|
||||||
let item: CustomItineraryItem
|
let item: ItineraryItem
|
||||||
let colorScheme: ColorScheme
|
let colorScheme: ColorScheme
|
||||||
|
|
||||||
|
private var customInfo: CustomInfo? {
|
||||||
|
item.customInfo
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: Theme.Spacing.sm) {
|
HStack(spacing: Theme.Spacing.sm) {
|
||||||
// Category icon (emoji)
|
// Category icon (emoji)
|
||||||
Text(item.category.icon)
|
if let info = customInfo {
|
||||||
.font(.title3)
|
Text(info.icon)
|
||||||
|
.font(.title3)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Text(item.title)
|
Text(info.title)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
|
||||||
// Map pin indicator for items with coordinates
|
// Map pin indicator for items with coordinates
|
||||||
if item.isMappable {
|
if info.isMappable {
|
||||||
Image(systemName: "mappin.circle.fill")
|
Image(systemName: "mappin.circle.fill")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Theme.warmOrange)
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Address subtitle (shown only if present)
|
// Address subtitle (shown only if present)
|
||||||
if let address = item.address, !address.isEmpty {
|
if let address = info.address, !address.isEmpty {
|
||||||
Text(address)
|
Text(address)
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(Theme.textMuted(colorScheme))
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,32 +12,32 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
|
|
||||||
let trip: Trip
|
let trip: Trip
|
||||||
let games: [RichGame]
|
let games: [RichGame]
|
||||||
let customItems: [CustomItineraryItem]
|
let itineraryItems: [ItineraryItem]
|
||||||
let travelDayOverrides: [String: Int]
|
let travelDayOverrides: [String: Int]
|
||||||
let headerContent: HeaderContent
|
let headerContent: HeaderContent
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onTravelMoved: ((String, Int) -> Void)?
|
var onTravelMoved: ((String, Int) -> Void)?
|
||||||
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
||||||
var onCustomItemTapped: ((CustomItineraryItem) -> Void)?
|
var onCustomItemTapped: ((ItineraryItem) -> Void)?
|
||||||
var onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
|
var onCustomItemDeleted: ((ItineraryItem) -> Void)?
|
||||||
var onAddButtonTapped: ((Int) -> Void)? // Just day number
|
var onAddButtonTapped: ((Int) -> Void)? // Just day number
|
||||||
|
|
||||||
init(
|
init(
|
||||||
trip: Trip,
|
trip: Trip,
|
||||||
games: [RichGame],
|
games: [RichGame],
|
||||||
customItems: [CustomItineraryItem],
|
itineraryItems: [ItineraryItem],
|
||||||
travelDayOverrides: [String: Int],
|
travelDayOverrides: [String: Int],
|
||||||
@ViewBuilder headerContent: () -> HeaderContent,
|
@ViewBuilder headerContent: () -> HeaderContent,
|
||||||
onTravelMoved: ((String, Int) -> Void)? = nil,
|
onTravelMoved: ((String, Int) -> Void)? = nil,
|
||||||
onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil,
|
onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil,
|
||||||
onCustomItemTapped: ((CustomItineraryItem) -> Void)? = nil,
|
onCustomItemTapped: ((ItineraryItem) -> Void)? = nil,
|
||||||
onCustomItemDeleted: ((CustomItineraryItem) -> Void)? = nil,
|
onCustomItemDeleted: ((ItineraryItem) -> Void)? = nil,
|
||||||
onAddButtonTapped: ((Int) -> Void)? = nil
|
onAddButtonTapped: ((Int) -> Void)? = nil
|
||||||
) {
|
) {
|
||||||
self.trip = trip
|
self.trip = trip
|
||||||
self.games = games
|
self.games = games
|
||||||
self.customItems = customItems
|
self.itineraryItems = itineraryItems
|
||||||
self.travelDayOverrides = travelDayOverrides
|
self.travelDayOverrides = travelDayOverrides
|
||||||
self.headerContent = headerContent()
|
self.headerContent = headerContent()
|
||||||
self.onTravelMoved = onTravelMoved
|
self.onTravelMoved = onTravelMoved
|
||||||
@@ -159,12 +159,13 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
// Travel before this day (travel is stored on the destination day)
|
// Travel before this day (travel is stored on the destination day)
|
||||||
let travelBefore: TravelSegment? = travelByDay[dayNum]
|
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)
|
// 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 }
|
.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
|
|
||||||
for item in dayItems {
|
for item in customItemsForDay {
|
||||||
items.append(ItineraryRowItem.customItem(item))
|
items.append(ItineraryRowItem.customItem(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ struct TripDetailView: View {
|
|||||||
@State private var loadedGames: [String: RichGame] = [:]
|
@State private var loadedGames: [String: RichGame] = [:]
|
||||||
@State private var isLoadingGames = false
|
@State private var isLoadingGames = false
|
||||||
|
|
||||||
// Custom items state
|
// Itinerary items state
|
||||||
@State private var customItems: [CustomItineraryItem] = []
|
@State private var itineraryItems: [ItineraryItem] = []
|
||||||
@State private var addItemAnchor: AddItemAnchor?
|
@State private var addItemAnchor: AddItemAnchor?
|
||||||
@State private var editingItem: CustomItineraryItem?
|
@State private var editingItem: ItineraryItem?
|
||||||
@State private var subscriptionCancellable: AnyCancellable?
|
@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 draggedTravelId: String? // Track which travel segment is being dragged
|
||||||
@State private var dropTargetId: String? // Track which drop zone is being hovered
|
@State private var dropTargetId: String? // Track which drop zone is being hovered
|
||||||
@State private var travelDayOverrides: [String: Int] = [:] // Key: travel ID, Value: day number
|
@State private var travelDayOverrides: [String: Int] = [:] // Key: travel ID, Value: day number
|
||||||
@@ -122,7 +122,7 @@ struct TripDetailView: View {
|
|||||||
day: anchor.day,
|
day: anchor.day,
|
||||||
existingItem: nil
|
existingItem: nil
|
||||||
) { item in
|
) { item in
|
||||||
Task { await saveCustomItem(item) }
|
Task { await saveItineraryItem(item) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(item: $editingItem) { item in
|
.sheet(item: $editingItem) { item in
|
||||||
@@ -131,7 +131,7 @@ struct TripDetailView: View {
|
|||||||
day: item.day,
|
day: item.day,
|
||||||
existingItem: item
|
existingItem: item
|
||||||
) { updatedItem in
|
) { updatedItem in
|
||||||
Task { await saveCustomItem(updatedItem) }
|
Task { await saveItineraryItem(updatedItem) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onAppear {
|
.onAppear {
|
||||||
@@ -140,21 +140,23 @@ struct TripDetailView: View {
|
|||||||
.task {
|
.task {
|
||||||
await loadGamesIfNeeded()
|
await loadGamesIfNeeded()
|
||||||
if allowCustomItems {
|
if allowCustomItems {
|
||||||
await loadCustomItems()
|
await loadItineraryItems()
|
||||||
await setupSubscription()
|
await setupSubscription()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onDisappear {
|
.onDisappear {
|
||||||
subscriptionCancellable?.cancel()
|
subscriptionCancellable?.cancel()
|
||||||
}
|
}
|
||||||
.onChange(of: customItems) { _, newItems in
|
.onChange(of: itineraryItems) { _, newItems in
|
||||||
// Clear drag state after items update (move completed)
|
// Clear drag state after items update (move completed)
|
||||||
draggedItem = nil
|
draggedItem = nil
|
||||||
dropTargetId = nil
|
dropTargetId = nil
|
||||||
// Recalculate routes when custom items change (mappable items affect route)
|
// Recalculate routes when custom items change (mappable items affect route)
|
||||||
print("🗺️ [MapUpdate] customItems changed, count: \(newItems.count)")
|
print("🗺️ [MapUpdate] itineraryItems changed, count: \(newItems.count)")
|
||||||
for item in newItems where item.isMappable {
|
for item in newItems {
|
||||||
print("🗺️ [MapUpdate] Mappable: \(item.title) on day \(item.day), sortOrder: \(item.sortOrder)")
|
if item.isCustom, let info = item.customInfo, info.isMappable {
|
||||||
|
print("🗺️ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Task {
|
Task {
|
||||||
updateMapRegion()
|
updateMapRegion()
|
||||||
@@ -182,7 +184,7 @@ struct TripDetailView: View {
|
|||||||
ItineraryTableViewWrapper(
|
ItineraryTableViewWrapper(
|
||||||
trip: trip,
|
trip: trip,
|
||||||
games: Array(games.values),
|
games: Array(games.values),
|
||||||
customItems: customItems,
|
itineraryItems: itineraryItems,
|
||||||
travelDayOverrides: travelDayOverrides,
|
travelDayOverrides: travelDayOverrides,
|
||||||
headerContent: {
|
headerContent: {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -222,7 +224,7 @@ struct TripDetailView: View {
|
|||||||
},
|
},
|
||||||
onCustomItemMoved: { itemId, day, sortOrder in
|
onCustomItemMoved: { itemId, day, sortOrder in
|
||||||
Task { @MainActor 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)
|
await moveItem(item, toDay: day, sortOrder: sortOrder)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -230,7 +232,7 @@ struct TripDetailView: View {
|
|||||||
editingItem = item
|
editingItem = item
|
||||||
},
|
},
|
||||||
onCustomItemDeleted: { item in
|
onCustomItemDeleted: { item in
|
||||||
Task { await deleteCustomItem(item) }
|
Task { await deleteItineraryItem(item) }
|
||||||
},
|
},
|
||||||
onAddButtonTapped: { day in
|
onAddButtonTapped: { day in
|
||||||
addItemAnchor = AddItemAnchor(day: day)
|
addItemAnchor = AddItemAnchor(day: day)
|
||||||
@@ -570,9 +572,15 @@ struct TripDetailView: View {
|
|||||||
let isDraggingThis = draggedItem?.id == item.id
|
let isDraggingThis = draggedItem?.id == item.id
|
||||||
CustomItemRow(
|
CustomItemRow(
|
||||||
item: item,
|
item: item,
|
||||||
onTap: { editingItem = item },
|
onTap: { editingItem = item }
|
||||||
onDelete: { Task { await deleteCustomItem(item) } }
|
|
||||||
)
|
)
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
Task { await deleteItineraryItem(item) }
|
||||||
|
} label: {
|
||||||
|
Label("Delete", systemImage: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
.opacity(isDraggingThis ? 0.4 : 1.0)
|
.opacity(isDraggingThis ? 0.4 : 1.0)
|
||||||
.staggeredAnimation(index: index)
|
.staggeredAnimation(index: index)
|
||||||
.overlay(alignment: .top) {
|
.overlay(alignment: .top) {
|
||||||
@@ -644,12 +652,12 @@ struct TripDetailView: View {
|
|||||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||||
guard let droppedId = item as? String,
|
guard let droppedId = item as? String,
|
||||||
let itemId = UUID(uuidString: droppedId),
|
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
|
Task { @MainActor in
|
||||||
let day = self.findDayForTravelSegment(segment)
|
let day = self.findDayForTravelSegment(segment)
|
||||||
// Place at beginning of day (sortOrder before existing items)
|
// 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 }
|
.filter { $0.day == day && $0.id != droppedItem.id }
|
||||||
.map { $0.sortOrder }
|
.map { $0.sortOrder }
|
||||||
.min() ?? 1.0
|
.min() ?? 1.0
|
||||||
@@ -659,7 +667,7 @@ struct TripDetailView: View {
|
|||||||
return true
|
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 {
|
guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -672,12 +680,12 @@ struct TripDetailView: View {
|
|||||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||||
guard let droppedId = item as? String,
|
guard let droppedId = item as? String,
|
||||||
let itemId = UUID(uuidString: droppedId),
|
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 }
|
droppedItem.id != targetItem.id else { return }
|
||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// Place before target item using midpoint insertion
|
// 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 }
|
.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
let targetIdx = itemsInDay.firstIndex(where: { $0.id == targetItem.id }) ?? 0
|
let targetIdx = itemsInDay.firstIndex(where: { $0.id == targetItem.id }) ?? 0
|
||||||
let prevSortOrder = targetIdx > 0 ? itemsInDay[targetIdx - 1].sortOrder : 0.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
|
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||||
guard let droppedId = item as? String,
|
guard let droppedId = item as? String,
|
||||||
let itemId = UUID(uuidString: droppedId),
|
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
|
Task { @MainActor in
|
||||||
// Calculate sortOrder: append at end of day's items
|
// 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 }
|
.filter { $0.day == day && $0.id != droppedItem.id }
|
||||||
.map { $0.sortOrder }
|
.map { $0.sortOrder }
|
||||||
.max() ?? 0.0
|
.max() ?? 0.0
|
||||||
@@ -759,26 +767,23 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Move item to a new day and sortOrder position
|
/// 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
|
var updated = item
|
||||||
updated.day = day
|
updated.day = day
|
||||||
updated.sortOrder = sortOrder
|
updated.sortOrder = sortOrder
|
||||||
updated.modifiedAt = Date()
|
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
|
// Update local state
|
||||||
if let idx = customItems.firstIndex(where: { $0.id == item.id }) {
|
if let idx = itineraryItems.firstIndex(where: { $0.id == item.id }) {
|
||||||
customItems[idx] = updated
|
itineraryItems[idx] = updated
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync to CloudKit
|
// Sync to CloudKit (debounced)
|
||||||
do {
|
await ItineraryItemService.shared.updateItem(updated)
|
||||||
_ = try await CustomItemService.shared.updateItem(updated)
|
print("✅ [Move] Synced \(title) with day: \(day), sortOrder: \(sortOrder)")
|
||||||
print("✅ [Move] Synced \(updated.title) with day: \(day), sortOrder: \(sortOrder)")
|
|
||||||
} catch {
|
|
||||||
print("❌ [Move] Failed to sync: \(error)")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build itinerary sections: shows ALL days with travel, custom items, and add buttons
|
/// 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
|
// Add button first - always right after day header
|
||||||
sections.append(.addButton(day: dayNum))
|
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 }
|
.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
for item in dayItems {
|
for item in dayItems {
|
||||||
sections.append(.customItem(item))
|
sections.append(.customItem(item))
|
||||||
@@ -1021,8 +1027,8 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Mappable custom items for display on the map
|
/// Mappable custom items for display on the map
|
||||||
private var mappableCustomItems: [CustomItineraryItem] {
|
private var mappableCustomItems: [ItineraryItem] {
|
||||||
customItems.filter { $0.isMappable }
|
itineraryItems.filter { $0.isCustom && $0.customInfo?.isMappable == true }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert stored route coordinates to MKPolyline for rendering
|
/// Convert stored route coordinates to MKPolyline for rendering
|
||||||
@@ -1041,7 +1047,8 @@ struct TripDetailView: View {
|
|||||||
print("🗺️ [Waypoints] Building waypoints. Mappable items by day:")
|
print("🗺️ [Waypoints] Building waypoints. Mappable items by day:")
|
||||||
for (day, items) in itemsByDay.sorted(by: { $0.key < $1.key }) {
|
for (day, items) in itemsByDay.sorted(by: { $0.key < $1.key }) {
|
||||||
for item in items.sorted(by: { $0.sortOrder < $1.sortOrder }) {
|
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] {
|
if let items = itemsByDay[dayNumber] {
|
||||||
let sortedItems = items.sorted { $0.sortOrder < $1.sortOrder }
|
let sortedItems = items.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
for item in sortedItems {
|
for item in sortedItems {
|
||||||
if let coord = item.coordinate {
|
if let info = item.customInfo, let coord = info.coordinate {
|
||||||
print("🗺️ [Waypoints] Adding \(item.title) (sortOrder: \(item.sortOrder))")
|
print("🗺️ [Waypoints] Adding \(info.title) (sortOrder: \(item.sortOrder))")
|
||||||
waypoints.append((item.title, coord, true))
|
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 {
|
private func setupSubscription() async {
|
||||||
// Subscribe to real-time updates for this trip
|
// TODO: Re-implement CloudKit subscription for ItineraryItem changes
|
||||||
do {
|
// The subscription service was removed during the ItineraryItem refactor.
|
||||||
try await CustomItemSubscriptionService.shared.subscribeToTrip(trip.id)
|
// For now, items are only loaded on view appear.
|
||||||
|
print("📡 [Subscription] CloudKit subscriptions not yet implemented for ItineraryItem")
|
||||||
|
}
|
||||||
|
|
||||||
// Listen for changes and reload
|
private func loadItineraryItems() async {
|
||||||
subscriptionCancellable = await CustomItemSubscriptionService.shared.changePublisher
|
print("🔍 [ItineraryItems] Loading items for trip: \(trip.id)")
|
||||||
.filter { $0 == self.trip.id }
|
do {
|
||||||
.receive(on: DispatchQueue.main)
|
let items = try await ItineraryItemService.shared.fetchItems(forTripId: trip.id)
|
||||||
.sink { [self] _ in
|
print("✅ [ItineraryItems] Loaded \(items.count) items from CloudKit")
|
||||||
print("📡 [Subscription] Received update, reloading custom items...")
|
itineraryItems = items
|
||||||
Task {
|
|
||||||
await loadCustomItems()
|
// 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
|
travelDayOverrides = overrides
|
||||||
|
print("✅ [TravelOverrides] Extracted \(overrides.count) travel day overrides")
|
||||||
} catch {
|
} 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
|
// Check if this is an update or create
|
||||||
let isUpdate = customItems.contains(where: { $0.id == item.id })
|
let isUpdate = itineraryItems.contains(where: { $0.id == item.id })
|
||||||
print("💾 [CustomItems] Saving item: '\(item.title)' (isUpdate: \(isUpdate))")
|
let title = item.customInfo?.title ?? "item"
|
||||||
|
print("💾 [ItineraryItems] Saving item: '\(title)' (isUpdate: \(isUpdate))")
|
||||||
print(" - tripId: \(item.tripId)")
|
print(" - tripId: \(item.tripId)")
|
||||||
print(" - day: \(item.day), sortOrder: \(item.sortOrder)")
|
print(" - day: \(item.day), sortOrder: \(item.sortOrder)")
|
||||||
|
|
||||||
// Update local state immediately for responsive UI
|
// Update local state immediately for responsive UI
|
||||||
if isUpdate {
|
if isUpdate {
|
||||||
if let index = customItems.firstIndex(where: { $0.id == item.id }) {
|
if let index = itineraryItems.firstIndex(where: { $0.id == item.id }) {
|
||||||
customItems[index] = item
|
itineraryItems[index] = item
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
customItems.append(item)
|
itineraryItems.append(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist to CloudKit
|
// Persist to CloudKit
|
||||||
do {
|
do {
|
||||||
if isUpdate {
|
if isUpdate {
|
||||||
let updated = try await CustomItemService.shared.updateItem(item)
|
await ItineraryItemService.shared.updateItem(item)
|
||||||
print("✅ [CustomItems] Updated in CloudKit: \(updated.title)")
|
print("✅ [ItineraryItems] Updated in CloudKit: \(title)")
|
||||||
} else {
|
} else {
|
||||||
let created = try await CustomItemService.shared.createItem(item)
|
_ = try await ItineraryItemService.shared.createItem(item)
|
||||||
print("✅ [CustomItems] Created in CloudKit: \(created.title)")
|
print("✅ [ItineraryItems] Created in CloudKit: \(title)")
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ [CustomItems] CloudKit save failed: \(error)")
|
print("❌ [ItineraryItems] CloudKit save failed: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func deleteCustomItem(_ item: CustomItineraryItem) async {
|
private func deleteItineraryItem(_ item: ItineraryItem) async {
|
||||||
print("🗑️ [CustomItems] Deleting item: '\(item.title)'")
|
let title = item.customInfo?.title ?? "item"
|
||||||
|
print("🗑️ [ItineraryItems] Deleting item: '\(title)'")
|
||||||
|
|
||||||
// Remove from local state immediately
|
// Remove from local state immediately
|
||||||
customItems.removeAll { $0.id == item.id }
|
itineraryItems.removeAll { $0.id == item.id }
|
||||||
|
|
||||||
// Delete from CloudKit
|
// Delete from CloudKit
|
||||||
do {
|
do {
|
||||||
try await CustomItemService.shared.deleteItem(item.id)
|
try await ItineraryItemService.shared.deleteItem(item.id)
|
||||||
print("✅ [CustomItems] Deleted from CloudKit")
|
print("✅ [ItineraryItems] Deleted from CloudKit")
|
||||||
} catch {
|
} 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
|
// Otherwise, it's a custom item drop
|
||||||
guard let itemId = UUID(uuidString: droppedId),
|
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
|
// Append at end of day's items
|
||||||
let maxSortOrder = self.customItems
|
let maxSortOrder = self.itineraryItems
|
||||||
.filter { $0.day == dayNumber && $0.id != item.id }
|
.filter { $0.day == dayNumber && $0.id != item.id }
|
||||||
.map { $0.sortOrder }
|
.map { $0.sortOrder }
|
||||||
.max() ?? 0.0
|
.max() ?? 0.0
|
||||||
@@ -1393,18 +1395,45 @@ struct TripDetailView: View {
|
|||||||
private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int) async {
|
private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int) async {
|
||||||
print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay)")
|
print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay)")
|
||||||
|
|
||||||
let override = TravelDayOverride(
|
// Parse travel ID to extract cities (format: "travel:city1->city2")
|
||||||
tripId: trip.id,
|
let stripped = travelAnchorId.replacingOccurrences(of: "travel:", with: "")
|
||||||
travelAnchorId: travelAnchorId,
|
let parts = stripped.components(separatedBy: "->")
|
||||||
displayDay: displayDay
|
guard parts.count == 2 else {
|
||||||
)
|
print("❌ [TravelOverrides] Invalid travel ID format: \(travelAnchorId)")
|
||||||
|
return
|
||||||
do {
|
|
||||||
_ = try await TravelOverrideService.shared.saveOverride(override)
|
|
||||||
print("✅ [TravelOverrides] Saved to CloudKit")
|
|
||||||
} catch {
|
|
||||||
print("❌ [TravelOverrides] CloudKit save failed: \(error)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
enum ItinerarySection {
|
||||||
case day(dayNumber: Int, date: Date, games: [RichGame])
|
case day(dayNumber: Int, date: Date, games: [RichGame])
|
||||||
case travel(TravelSegment)
|
case travel(TravelSegment)
|
||||||
case customItem(CustomItineraryItem)
|
case customItem(ItineraryItem)
|
||||||
case addButton(day: Int)
|
case addButton(day: Int)
|
||||||
|
|
||||||
var isCustomItem: Bool {
|
var isCustomItem: Bool {
|
||||||
@@ -1807,7 +1836,7 @@ struct TripMapView: View {
|
|||||||
@Binding var cameraPosition: MapCameraPosition
|
@Binding var cameraPosition: MapCameraPosition
|
||||||
let routeCoordinates: [[CLLocationCoordinate2D]]
|
let routeCoordinates: [[CLLocationCoordinate2D]]
|
||||||
let stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)]
|
let stopCoordinates: [(name: String, coordinate: CLLocationCoordinate2D)]
|
||||||
let customItems: [CustomItineraryItem]
|
let customItems: [ItineraryItem]
|
||||||
let colorScheme: ColorScheme
|
let colorScheme: ColorScheme
|
||||||
let routeVersion: UUID // Force re-render when routes change
|
let routeVersion: UUID // Force re-render when routes change
|
||||||
|
|
||||||
@@ -1841,15 +1870,14 @@ struct TripMapView: View {
|
|||||||
|
|
||||||
// Custom item markers
|
// Custom item markers
|
||||||
ForEach(customItems, id: \.id) { item in
|
ForEach(customItems, id: \.id) { item in
|
||||||
if let coordinate = item.coordinate {
|
if let info = item.customInfo, let coordinate = info.coordinate {
|
||||||
Annotation(item.title, coordinate: coordinate) {
|
Annotation(info.title, coordinate: coordinate) {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
.fill(Theme.warmOrange)
|
.fill(Theme.warmOrange)
|
||||||
.frame(width: 24, height: 24)
|
.frame(width: 24, height: 24)
|
||||||
Image(systemName: item.category.systemImage)
|
Text(info.icon)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user