wip
This commit is contained in:
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user