This commit is contained in:
Trey t
2026-01-18 12:32:58 -06:00
parent cd1666e7d1
commit 143b364553
4 changed files with 812 additions and 481 deletions

View File

@@ -42,7 +42,7 @@ struct TripDetailView: View {
@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
@State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder
private let exportService = ExportService()
private let dataProvider = AppDataProvider.shared
@@ -81,98 +81,82 @@ struct TripDetailView: View {
}
var body: some View {
mainContent
.background(Theme.backgroundGradient(colorScheme))
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
.toolbar {
ToolbarItemGroup(placement: .primaryAction) {
ShareButton(trip: trip, style: .icon)
.foregroundStyle(Theme.warmOrange)
bodyContent
}
Button {
if StoreManager.shared.isPro {
Task {
await exportPDF()
}
} else {
showProPaywall = true
}
} label: {
HStack(spacing: 2) {
Image(systemName: "doc.fill")
if !StoreManager.shared.isPro {
ProBadge()
}
}
.foregroundStyle(Theme.warmOrange)
@ViewBuilder
private var bodyContent: some View {
mainContent
.background(Theme.backgroundGradient(colorScheme))
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
.toolbar { toolbarContent }
.modifier(SheetModifiers(
showExportSheet: $showExportSheet,
exportURL: exportURL,
showProPaywall: $showProPaywall,
addItemAnchor: $addItemAnchor,
editingItem: $editingItem,
tripId: trip.id,
saveItineraryItem: saveItineraryItem
))
.onAppear { checkIfSaved() }
.task {
await loadGamesIfNeeded()
if allowCustomItems {
await loadItineraryItems()
await setupSubscription()
}
}
}
.sheet(isPresented: $showExportSheet) {
if let url = exportURL {
ShareSheet(items: [url])
.onDisappear { subscriptionCancellable?.cancel() }
.onChange(of: itineraryItems) { _, newItems in
handleItineraryItemsChange(newItems)
}
}
.sheet(isPresented: $showProPaywall) {
PaywallView()
}
.sheet(item: $addItemAnchor) { anchor in
AddItemSheet(
tripId: trip.id,
day: anchor.day,
existingItem: nil
) { item in
Task { await saveItineraryItem(item) }
.onChange(of: travelOverrides.count) { _, _ in
draggedTravelId = nil
dropTargetId = nil
}
}
.sheet(item: $editingItem) { item in
AddItemSheet(
tripId: trip.id,
day: item.day,
existingItem: item
) { updatedItem in
Task { await saveItineraryItem(updatedItem) }
.overlay {
if isExporting { exportProgressOverlay }
}
}
.onAppear {
checkIfSaved()
}
.task {
await loadGamesIfNeeded()
if allowCustomItems {
await loadItineraryItems()
await setupSubscription()
}
}
.onDisappear {
subscriptionCancellable?.cancel()
}
.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] 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)")
}
@ToolbarContentBuilder
private var toolbarContent: some ToolbarContent {
ToolbarItemGroup(placement: .primaryAction) {
ShareButton(trip: trip, style: .icon)
.foregroundStyle(Theme.warmOrange)
Button {
if StoreManager.shared.isPro {
Task { await exportPDF() }
} else {
showProPaywall = true
}
}
Task {
updateMapRegion()
await fetchDrivingRoutes()
} label: {
HStack(spacing: 2) {
Image(systemName: "doc.fill")
if !StoreManager.shared.isPro {
ProBadge()
}
}
.foregroundStyle(Theme.warmOrange)
}
}
.onChange(of: travelDayOverrides) { _, _ in
// Clear drag state after travel move completed
draggedTravelId = nil
dropTargetId = nil
}
.overlay {
if isExporting {
exportProgressOverlay
}
private func handleItineraryItemsChange(_ newItems: [ItineraryItem]) {
draggedItem = nil
dropTargetId = nil
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()
await fetchDrivingRoutes()
}
}
// MARK: - Main Content
@@ -185,7 +169,7 @@ struct TripDetailView: View {
trip: trip,
games: Array(games.values),
itineraryItems: itineraryItems,
travelDayOverrides: travelDayOverrides,
travelOverrides: travelOverrides,
headerContent: {
VStack(spacing: 0) {
// Hero Map
@@ -214,10 +198,10 @@ struct TripDetailView: View {
.padding(.bottom, Theme.Spacing.md)
}
},
onTravelMoved: { travelId, newDay in
onTravelMoved: { travelId, newDay, newSortOrder in
Task { @MainActor in
withAnimation {
travelDayOverrides[travelId] = newDay
travelOverrides[travelId] = TravelOverride(day: newDay, sortOrder: newSortOrder)
}
await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay)
}
@@ -818,8 +802,9 @@ struct TripDetailView: View {
}
// Check for user override - only use if within valid range
if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) {
travelByDay[overrideDay] = segment
if let override = travelOverrides[travelId],
validRange.contains(override.day) {
travelByDay[override.day] = segment
} else {
// Use default (clamped to valid range)
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
@@ -1276,16 +1261,18 @@ struct TripDetailView: View {
print("✅ [ItineraryItems] Loaded \(items.count) items from CloudKit")
itineraryItems = items
// Extract travel day overrides from travel-type items
var overrides: [String: Int] = [:]
// Extract travel overrides (day + sortOrder) from travel-type items
var overrides: [String: TravelOverride] = [:]
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
}
guard let travelInfo = item.travelInfo else { continue }
let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())"
overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder)
}
travelDayOverrides = overrides
print("✅ [TravelOverrides] Extracted \(overrides.count) travel day overrides")
travelOverrides = overrides
print("✅ [TravelOverrides] Extracted \(overrides.count) travel overrides (day + sortOrder)")
} catch {
print("❌ [ItineraryItems] Failed to load: \(error)")
}
@@ -1357,21 +1344,35 @@ struct TripDetailView: View {
Task { @MainActor in
// Check if this is a travel segment being dropped
if droppedId.hasPrefix("travel:") {
// Validate travel is within valid bounds
// Validate travel is within valid bounds (day-level)
if let validRange = self.validDayRange(for: droppedId) {
guard validRange.contains(dayNumber) else {
// Day is outside valid range - reject drop (state already cleared)
return
}
}
// Move travel to this day
// Choose a semantic sortOrder for dropping onto a day:
// - If this day has games, default to AFTER games (positive)
// - If no games, default to 1.0
//
// You can later support "before games" drops by using a negative sortOrder
// when the user drops above the games row.
let maxSortOrderOnDay = self.itineraryItems
.filter { $0.day == dayNumber }
.map { $0.sortOrder }
.max() ?? 0.0
let newSortOrder = max(maxSortOrderOnDay + 1.0, 1.0)
withAnimation {
self.travelDayOverrides[droppedId] = dayNumber
self.travelOverrides[droppedId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder)
}
// Persist the override to CloudKit
await self.saveTravelDayOverride(travelAnchorId: droppedId, displayDay: dayNumber)
// Persist to CloudKit as a travel ItineraryItem
await self.saveTravelDayOverride(
travelAnchorId: droppedId,
displayDay: dayNumber
)
return
}
@@ -1901,3 +1902,52 @@ struct TripMapView: View {
)
}
}
// MARK: - Travel Override
struct TravelOverride: Equatable {
let day: Int
let sortOrder: Double
}
// MARK: - Sheet Modifiers
private struct SheetModifiers: ViewModifier {
@Binding var showExportSheet: Bool
let exportURL: URL?
@Binding var showProPaywall: Bool
@Binding var addItemAnchor: AddItemAnchor?
@Binding var editingItem: ItineraryItem?
let tripId: UUID
let saveItineraryItem: (ItineraryItem) async -> Void
func body(content: Content) -> some View {
content
.sheet(isPresented: $showExportSheet) {
if let url = exportURL {
ShareSheet(items: [url])
}
}
.sheet(isPresented: $showProPaywall) {
PaywallView()
}
.sheet(item: $addItemAnchor) { anchor in
AddItemSheet(
tripId: tripId,
day: anchor.day,
existingItem: nil
) { item in
Task { await saveItineraryItem(item) }
}
}
.sheet(item: $editingItem) { item in
AddItemSheet(
tripId: tripId,
day: item.day,
existingItem: item
) { updatedItem in
Task { await saveItineraryItem(updatedItem) }
}
}
}
}