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:
Trey t
2026-01-16 22:35:27 -06:00
parent bf9619a207
commit 43501b6ac1
3 changed files with 1094 additions and 38 deletions

View File

@@ -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)