feat(itinerary): add custom itinerary items with drag-to-reorder

- Add CustomItineraryItem domain model with sortOrder for ordering
- Add CKCustomItineraryItem CloudKit wrapper for persistence
- Create CustomItemService for CRUD operations
- Create CustomItemSubscriptionService for real-time sync
- Add AppDelegate for push notification handling
- Add AddItemSheet for creating/editing items
- Add CustomItemRow with drag handle
- Update TripDetailView with continuous vertical timeline
- Enable drag-to-reorder using .draggable/.dropDestination
- Add inline "Add" buttons after games and travel segments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-16 00:31:44 -06:00
parent b534ca771b
commit 495ef88303
13 changed files with 1302 additions and 33 deletions

View File

@@ -6,6 +6,7 @@
import SwiftUI
import SwiftData
import MapKit
import Combine
struct TripDetailView: View {
@Environment(\.modelContext) private var modelContext
@@ -14,6 +15,9 @@ struct TripDetailView: View {
let trip: Trip
private let providedGames: [String: RichGame]?
/// When true, shows Add buttons and custom items (only for saved trips)
private let allowCustomItems: Bool
@Query private var savedTrips: [SavedTrip]
@State private var showProPaywall = false
@State private var selectedDay: ItineraryDay?
@@ -28,6 +32,13 @@ struct TripDetailView: View {
@State private var loadedGames: [String: RichGame] = [:]
@State private var isLoadingGames = false
// Custom items state
@State private var customItems: [CustomItineraryItem] = []
@State private var addItemAnchor: AddItemAnchor?
@State private var editingItem: CustomItineraryItem?
@State private var subscriptionCancellable: AnyCancellable?
@State private var draggedItem: CustomItineraryItem?
private let exportService = ExportService()
private let dataProvider = AppDataProvider.shared
@@ -36,16 +47,32 @@ struct TripDetailView: View {
providedGames ?? loadedGames
}
/// Initialize with trip and games dictionary (existing callers)
/// Initialize with trip and games dictionary (existing callers - no custom items)
init(trip: Trip, games: [String: RichGame]) {
self.trip = trip
self.providedGames = games
self.allowCustomItems = false
}
/// Initialize with just trip - games will be loaded from AppDataProvider
/// Initialize with just trip - games will be loaded from AppDataProvider (no custom items)
init(trip: Trip) {
self.trip = trip
self.providedGames = nil
self.allowCustomItems = false
}
/// Initialize for saved trip context - enables custom items
init(trip: Trip, games: [String: RichGame], allowCustomItems: Bool) {
self.trip = trip
self.providedGames = games
self.allowCustomItems = allowCustomItems
}
/// Initialize for saved trip context without provided games - loads from AppDataProvider
init(trip: Trip, allowCustomItems: Bool) {
self.trip = trip
self.providedGames = nil
self.allowCustomItems = allowCustomItems
}
var body: some View {
@@ -110,11 +137,40 @@ struct TripDetailView: View {
.sheet(isPresented: $showProPaywall) {
PaywallView()
}
.sheet(item: $addItemAnchor) { anchor in
AddItemSheet(
tripId: trip.id,
anchorDay: anchor.day,
anchorType: anchor.type,
anchorId: anchor.anchorId,
existingItem: nil
) { item in
Task { await saveCustomItem(item) }
}
}
.sheet(item: $editingItem) { item in
AddItemSheet(
tripId: trip.id,
anchorDay: item.anchorDay,
anchorType: item.anchorType,
anchorId: item.anchorId,
existingItem: item
) { updatedItem in
Task { await saveCustomItem(updatedItem) }
}
}
.onAppear {
checkIfSaved()
}
.task {
await loadGamesIfNeeded()
if allowCustomItems {
await loadCustomItems()
await setupSubscription()
}
}
.onDisappear {
subscriptionCancellable?.cancel()
}
.overlay {
if isExporting {
@@ -330,25 +386,165 @@ struct TripDetailView: View {
Spacer()
}
} else {
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
switch section {
case .day(let dayNumber, let date, let gamesOnDay):
DaySection(
dayNumber: dayNumber,
date: date,
games: gamesOnDay
)
.staggeredAnimation(index: index)
case .travel(let segment):
TravelSection(segment: segment)
.staggeredAnimation(index: index)
}
// ZStack with continuous vertical line behind all content
ZStack(alignment: .top) {
// Continuous vertical line down the center
Rectangle()
.fill(Theme.routeGold.opacity(0.4))
.frame(width: 2)
.frame(maxHeight: .infinity)
// Itinerary content
VStack(spacing: Theme.Spacing.md) {
ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in
itineraryRow(for: section, at: index)
}
}
}
}
}
}
/// Build itinerary sections: group by day AND city, with travel between different cities
@ViewBuilder
private func itineraryRow(for section: ItinerarySection, at index: Int) -> some View {
switch section {
case .day(let dayNumber, let date, let gamesOnDay):
DaySection(
dayNumber: dayNumber,
date: date,
games: gamesOnDay
)
.staggeredAnimation(index: index)
.dropDestination(for: String.self) { items, _ in
guard let itemIdString = items.first,
let itemId = UUID(uuidString: itemIdString),
let item = customItems.first(where: { $0.id == itemId }),
let lastGame = gamesOnDay.last else { return false }
Task {
await moveItem(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id)
}
return true
}
case .travel(let segment):
TravelSection(segment: segment)
.staggeredAnimation(index: index)
.dropDestination(for: String.self) { items, _ in
guard let itemIdString = items.first,
let itemId = UUID(uuidString: itemIdString),
let item = customItems.first(where: { $0.id == itemId }) else { return false }
// Find the day for this travel segment
let day = findDayForTravelSegment(segment)
Task {
await moveItem(item, toDay: day, anchorType: .afterTravel, anchorId: segment.id.uuidString)
}
return true
}
case .customItem(let item):
CustomItemRow(
item: item,
onTap: { editingItem = item },
onDelete: { Task { await deleteCustomItem(item) } }
)
.staggeredAnimation(index: index)
.draggable(item.id.uuidString) {
// Drag preview
CustomItemRow(
item: item,
onTap: {},
onDelete: {}
)
.frame(width: 300)
.opacity(0.8)
}
.dropDestination(for: String.self) { items, _ in
guard let itemIdString = items.first,
let itemId = UUID(uuidString: itemIdString),
let draggedItem = customItems.first(where: { $0.id == itemId }),
draggedItem.id != item.id else { return false }
Task {
await moveItem(draggedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item)
}
return true
}
case .addButton(let day, let anchorType, let anchorId):
InlineAddButton {
addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId)
}
.dropDestination(for: String.self) { items, _ in
guard let itemIdString = items.first,
let itemId = UUID(uuidString: itemIdString),
let item = customItems.first(where: { $0.id == itemId }) else { return false }
Task {
await moveItem(item, toDay: day, anchorType: anchorType, anchorId: anchorId)
}
return true
}
}
}
private func findDayForTravelSegment(_ segment: TravelSegment) -> Int {
// Find which day this travel segment belongs to by looking at sections
for (index, section) in itinerarySections.enumerated() {
if case .travel(let s) = section, s.id == segment.id {
// Look backwards to find the day
for i in stride(from: index - 1, through: 0, by: -1) {
if case .day(let dayNumber, _, _) = itinerarySections[i] {
return dayNumber
}
}
}
}
return 1
}
private func moveItem(_ item: CustomItineraryItem, toDay day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?, beforeItem: CustomItineraryItem? = nil) async {
var updated = item
updated.anchorDay = day
updated.anchorType = anchorType
updated.anchorId = anchorId
// Calculate sortOrder
let itemsAtSameAnchor = customItems.filter {
$0.anchorDay == day &&
$0.anchorType == anchorType &&
$0.anchorId == anchorId &&
$0.id != item.id
}.sorted { $0.sortOrder < $1.sortOrder }
if let beforeItem = beforeItem,
let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) {
updated.sortOrder = beforeIndex
// Shift other items
for i in beforeIndex..<itemsAtSameAnchor.count {
if var shiftItem = customItems.first(where: { $0.id == itemsAtSameAnchor[i].id }) {
shiftItem.sortOrder = i + 1
if let idx = customItems.firstIndex(where: { $0.id == shiftItem.id }) {
customItems[idx] = shiftItem
}
}
}
} else {
updated.sortOrder = itemsAtSameAnchor.count
}
// Update local state
if let idx = customItems.firstIndex(where: { $0.id == item.id }) {
customItems[idx] = updated
}
// Sync to CloudKit
do {
_ = try await CustomItemService.shared.updateItem(updated)
print("✅ [Move] Moved item to day \(day), anchor: \(anchorType.rawValue)")
} catch {
print("❌ [Move] Failed to sync: \(error)")
}
}
/// Build itinerary sections: group by day AND city, with travel, custom items, and add buttons
private var itinerarySections: [ItinerarySection] {
var sections: [ItinerarySection] = []
@@ -381,7 +577,7 @@ struct TripDetailView: View {
}
}
// Build sections: insert travel BEFORE each section when coming from different city
// Build sections: insert travel, custom items (only if allowed), and add buttons
for (index, section) in dayCitySections.enumerated() {
// Check if we need travel BEFORE this section (coming from different city)
@@ -394,12 +590,54 @@ struct TripDetailView: View {
if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity {
if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) {
sections.append(.travel(travelSegment))
if allowCustomItems {
// Add button after travel
sections.append(.addButton(day: section.dayNumber, anchorType: .afterTravel, anchorId: travelSegment.id.uuidString))
// Custom items after this travel
let itemsAfterTravel = customItems.filter {
$0.anchorDay == section.dayNumber &&
$0.anchorType == .afterTravel &&
$0.anchorId == travelSegment.id.uuidString
}
for item in itemsAfterTravel {
sections.append(.customItem(item))
}
}
}
}
}
if allowCustomItems {
// Custom items at start of day
let itemsAtStart = customItems.filter {
$0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay
}
for item in itemsAtStart {
sections.append(.customItem(item))
}
}
// Add the day section
sections.append(.day(dayNumber: section.dayNumber, date: section.date, games: section.games))
if allowCustomItems {
// Add button after day's games
if let lastGame = section.games.last {
sections.append(.addButton(day: section.dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id))
// Custom items after this game
let itemsAfterGame = customItems.filter {
$0.anchorDay == section.dayNumber &&
$0.anchorType == .afterGame &&
$0.anchorId == lastGame.game.id
}
for item in itemsAfterGame {
sections.append(.customItem(item))
}
}
}
}
return sections
@@ -643,6 +881,87 @@ struct TripDetailView: View {
isSaved = true
}
}
// MARK: - Custom Items (CloudKit persistence)
private func setupSubscription() async {
// Subscribe to real-time updates for this trip
do {
try await CustomItemSubscriptionService.shared.subscribeToTrip(trip.id)
// 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()
}
}
} 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")
for item in items {
print(" - \(item.title) (day \(item.anchorDay), anchor: \(item.anchorType.rawValue))")
}
customItems = items
} catch {
print("❌ [CustomItems] Failed to load: \(error)")
}
}
private func saveCustomItem(_ item: CustomItineraryItem) 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))")
print(" - tripId: \(item.tripId)")
print(" - anchorDay: \(item.anchorDay), anchorType: \(item.anchorType.rawValue)")
// Update local state immediately for responsive UI
if isUpdate {
if let index = customItems.firstIndex(where: { $0.id == item.id }) {
customItems[index] = item
}
} else {
customItems.append(item)
}
// Persist to CloudKit
do {
if isUpdate {
let updated = try await CustomItemService.shared.updateItem(item)
print("✅ [CustomItems] Updated in CloudKit: \(updated.title)")
} else {
let created = try await CustomItemService.shared.createItem(item)
print("✅ [CustomItems] Created in CloudKit: \(created.title)")
}
} catch {
print("❌ [CustomItems] CloudKit save failed: \(error)")
}
}
private func deleteCustomItem(_ item: CustomItineraryItem) async {
print("🗑️ [CustomItems] Deleting item: '\(item.title)'")
// Remove from local state immediately
customItems.removeAll { $0.id == item.id }
// Delete from CloudKit
do {
try await CustomItemService.shared.deleteItem(item.id)
print("✅ [CustomItems] Deleted from CloudKit")
} catch {
print("❌ [CustomItems] CloudKit delete failed: \(error)")
}
}
}
// MARK: - Itinerary Section
@@ -650,6 +969,43 @@ struct TripDetailView: View {
enum ItinerarySection {
case day(dayNumber: Int, date: Date, games: [RichGame])
case travel(TravelSegment)
case customItem(CustomItineraryItem)
case addButton(day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?)
var isCustomItem: Bool {
if case .customItem = self { return true }
return false
}
}
// MARK: - Add Item Anchor (for sheet)
struct AddItemAnchor: Identifiable {
let id = UUID()
let day: Int
let type: CustomItineraryItem.AnchorType
let anchorId: String?
}
// MARK: - Inline Add Button
private struct InlineAddButton: View {
let action: () -> Void
var body: some View {
Button(action: action) {
HStack {
Image(systemName: "plus.circle.fill")
.foregroundStyle(Theme.warmOrange.opacity(0.6))
Text("Add")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
.padding(.horizontal, 8)
}
.buttonStyle(.plain)
}
}
// MARK: - Day Section
@@ -783,14 +1139,8 @@ struct TravelSection: View {
}
var body: some View {
// Travel card
VStack(spacing: 0) {
// Top connector
Rectangle()
.fill(Theme.routeGold.opacity(0.4))
.frame(width: 2, height: 16)
// Travel card
VStack(spacing: 0) {
// Main travel info
HStack(spacing: Theme.Spacing.md) {
// Icon
@@ -875,12 +1225,6 @@ struct TravelSection: View {
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
.strokeBorder(Theme.routeGold.opacity(0.3), lineWidth: 1)
}
// Bottom connector
Rectangle()
.fill(Theme.routeGold.opacity(0.4))
.frame(width: 2, height: 16)
}
}
}