feat(itinerary): add UITableView-based itinerary with unified scrolling
- Replace SwiftUI drag-drop with native UITableViewController for fluid reordering - Add ItineraryTableViewController with native cell reordering and validation - Add ItineraryTableViewWrapper for SwiftUI integration with header support - Fix infinite layout loop by tracking header adjustment state - Map and stats now scroll as table header with itinerary content - Travel segments constrained to valid day ranges during drag - One Add button per day (after game > after travel > rest day) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -80,40 +80,7 @@ struct TripDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
// Hero Map
|
||||
heroMapSection
|
||||
.frame(height: 280)
|
||||
|
||||
// Content
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
// Header
|
||||
tripHeader
|
||||
.padding(.top, Theme.Spacing.lg)
|
||||
|
||||
// Stats Row
|
||||
statsRow
|
||||
|
||||
// Score Card
|
||||
if let score = trip.score {
|
||||
scoreCard(score)
|
||||
}
|
||||
|
||||
// Day-by-Day Itinerary
|
||||
itinerarySection
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
.padding(.bottom, Theme.Spacing.xxl)
|
||||
}
|
||||
// Catch-all drop handler - clears drag state and accepts the drop to end drag
|
||||
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in
|
||||
draggedTravelId = nil
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
return true // Accept the drop to end the drag operation cleanly
|
||||
}
|
||||
}
|
||||
mainContent
|
||||
.background(Theme.backgroundGradient(colorScheme))
|
||||
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
||||
.toolbar {
|
||||
@@ -200,6 +167,102 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Content
|
||||
|
||||
@ViewBuilder
|
||||
private var mainContent: some View {
|
||||
if allowCustomItems {
|
||||
// Full-screen table with map as header
|
||||
ItineraryTableViewWrapper(
|
||||
trip: trip,
|
||||
games: Array(games.values),
|
||||
customItems: customItems,
|
||||
travelDayOverrides: travelDayOverrides,
|
||||
headerContent: {
|
||||
VStack(spacing: 0) {
|
||||
// Hero Map
|
||||
heroMapSection
|
||||
.frame(height: 280)
|
||||
|
||||
// Content header
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
tripHeader
|
||||
.padding(.top, Theme.Spacing.lg)
|
||||
|
||||
statsRow
|
||||
|
||||
if let score = trip.score {
|
||||
scoreCard(score)
|
||||
}
|
||||
|
||||
// Itinerary title
|
||||
Text("Itinerary")
|
||||
.font(.title2)
|
||||
.fontWeight(.bold)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
.padding(.bottom, Theme.Spacing.md)
|
||||
}
|
||||
},
|
||||
onTravelMoved: { travelId, newDay in
|
||||
Task { @MainActor in
|
||||
withAnimation {
|
||||
travelDayOverrides[travelId] = newDay
|
||||
}
|
||||
await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay)
|
||||
}
|
||||
},
|
||||
onCustomItemMoved: { itemId, day, anchorType, anchorId in
|
||||
Task { @MainActor in
|
||||
guard let item = customItems.first(where: { $0.id == itemId }) else { return }
|
||||
await moveItemToBeginning(item, toDay: day, anchorType: anchorType, anchorId: anchorId)
|
||||
}
|
||||
},
|
||||
onCustomItemTapped: { item in
|
||||
editingItem = item
|
||||
},
|
||||
onCustomItemDeleted: { item in
|
||||
Task { await deleteCustomItem(item) }
|
||||
},
|
||||
onAddButtonTapped: { day, anchorType, anchorId in
|
||||
addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId)
|
||||
}
|
||||
)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
} else {
|
||||
// Non-editable scroll view for unsaved trips
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
heroMapSection
|
||||
.frame(height: 280)
|
||||
|
||||
VStack(spacing: Theme.Spacing.lg) {
|
||||
tripHeader
|
||||
.padding(.top, Theme.Spacing.lg)
|
||||
|
||||
statsRow
|
||||
|
||||
if let score = trip.score {
|
||||
scoreCard(score)
|
||||
}
|
||||
|
||||
itinerarySection
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
.padding(.bottom, Theme.Spacing.xxl)
|
||||
}
|
||||
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in
|
||||
draggedTravelId = nil
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Export Progress Overlay
|
||||
|
||||
private var exportProgressOverlay: some View {
|
||||
@@ -391,7 +454,7 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Itinerary
|
||||
// MARK: - Itinerary (for non-editable scroll view)
|
||||
|
||||
private var itinerarySection: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.md) {
|
||||
@@ -407,15 +470,13 @@ struct TripDetailView: View {
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
// ZStack with continuous vertical line behind all content
|
||||
// Non-editable view for non-saved trips
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user