Files
Sportstime/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift
Trey t 43501b6ac1 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>
2026-01-16 22:35:27 -06:00

287 lines
12 KiB
Swift

//
// ItineraryTableViewWrapper.swift
// SportsTime
//
// UIViewControllerRepresentable wrapper for ItineraryTableViewController
//
import SwiftUI
struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresentable {
@Environment(\.colorScheme) private var colorScheme
let trip: Trip
let games: [RichGame]
let customItems: [CustomItineraryItem]
let travelDayOverrides: [String: Int]
let headerContent: HeaderContent
// Callbacks
var onTravelMoved: ((String, Int) -> Void)?
var onCustomItemMoved: ((UUID, Int, CustomItineraryItem.AnchorType, String?) -> Void)?
var onCustomItemTapped: ((CustomItineraryItem) -> Void)?
var onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
var onAddButtonTapped: ((Int, CustomItineraryItem.AnchorType, String?) -> Void)?
init(
trip: Trip,
games: [RichGame],
customItems: [CustomItineraryItem],
travelDayOverrides: [String: Int],
@ViewBuilder headerContent: () -> HeaderContent,
onTravelMoved: ((String, Int) -> Void)? = nil,
onCustomItemMoved: ((UUID, Int, CustomItineraryItem.AnchorType, String?) -> Void)? = nil,
onCustomItemTapped: ((CustomItineraryItem) -> Void)? = nil,
onCustomItemDeleted: ((CustomItineraryItem) -> Void)? = nil,
onAddButtonTapped: ((Int, CustomItineraryItem.AnchorType, String?) -> Void)? = nil
) {
self.trip = trip
self.games = games
self.customItems = customItems
self.travelDayOverrides = travelDayOverrides
self.headerContent = headerContent()
self.onTravelMoved = onTravelMoved
self.onCustomItemMoved = onCustomItemMoved
self.onCustomItemTapped = onCustomItemTapped
self.onCustomItemDeleted = onCustomItemDeleted
self.onAddButtonTapped = onAddButtonTapped
}
func makeUIViewController(context: Context) -> ItineraryTableViewController {
let controller = ItineraryTableViewController(style: .plain)
controller.colorScheme = colorScheme
controller.onTravelMoved = onTravelMoved
controller.onCustomItemMoved = onCustomItemMoved
controller.onCustomItemTapped = onCustomItemTapped
controller.onCustomItemDeleted = onCustomItemDeleted
controller.onAddButtonTapped = onAddButtonTapped
// Set header with proper sizing
let hostingController = UIHostingController(rootView: headerContent)
hostingController.view.backgroundColor = .clear
// Pre-size the header view
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
let targetWidth = UIScreen.main.bounds.width
let targetSize = CGSize(width: targetWidth, height: UIView.layoutFittingCompressedSize.height)
let size = hostingController.view.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
hostingController.view.frame = CGRect(origin: .zero, size: CGSize(width: targetWidth, height: max(size.height, 450)))
hostingController.view.translatesAutoresizingMaskIntoConstraints = true
controller.setTableHeader(hostingController.view)
// Load initial data
let (days, validRanges) = buildItineraryData()
controller.reloadData(days: days, travelValidRanges: validRanges)
return controller
}
func updateUIViewController(_ controller: ItineraryTableViewController, context: Context) {
controller.colorScheme = colorScheme
controller.onTravelMoved = onTravelMoved
controller.onCustomItemMoved = onCustomItemMoved
controller.onCustomItemTapped = onCustomItemTapped
controller.onCustomItemDeleted = onCustomItemDeleted
controller.onAddButtonTapped = onAddButtonTapped
// Note: Don't update header content here - it causes infinite layout loops
// Header is set once in makeUIViewController and remains static
let (days, validRanges) = buildItineraryData()
controller.reloadData(days: days, travelValidRanges: validRanges)
}
// MARK: - Build Itinerary Data
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>]) {
let tripDays = calculateTripDays()
var travelValidRanges: [String: ClosedRange<Int>] = [:]
// Pre-calculate travel segment placements
var travelByDay: [Int: TravelSegment] = [:]
for segment in trip.travelSegments {
let travelId = stableTravelAnchorId(segment)
let fromCity = segment.fromLocation.name
let toCity = segment.toLocation.name
// Calculate valid range
// Travel "on day N" appears BEFORE day N's header
// So minDay must be AFTER the last game day in departure city
let lastGameInFromCity = findLastGameDay(in: fromCity, tripDays: tripDays)
let firstGameInToCity = findFirstGameDay(in: toCity, tripDays: tripDays)
let minDay = max(lastGameInFromCity + 1, 1) // Day AFTER last game in from city
let maxDay = min(firstGameInToCity, tripDays.count) // Can arrive same day as first game
let validRange = minDay <= maxDay ? minDay...maxDay : maxDay...maxDay
travelValidRanges[travelId] = validRange
// Calculate default day
let defaultDay: Int
if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count {
defaultDay = lastGameInFromCity + 1
} else if lastGameInFromCity > 0 {
defaultDay = lastGameInFromCity
} else {
defaultDay = 1
}
// Use override if valid, otherwise use default
if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) {
travelByDay[overrideDay] = segment
} else {
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
travelByDay[clampedDefault] = segment
}
}
// Build day data
var days: [ItineraryDayData] = []
for (index, dayDate) in tripDays.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
var items: [ItineraryRowItem] = []
// Travel before this day (travel is stored on the destination day)
let travelBefore: TravelSegment? = travelByDay[dayNum]
// Custom items after travel (if travel arrives on this day)
if let travelSegment = travelBefore {
let travelId = stableTravelAnchorId(travelSegment)
let itemsAfterTravel = customItems.filter {
$0.anchorType == .afterTravel && $0.anchorId == travelId
}.sorted { $0.sortOrder < $1.sortOrder }
for item in itemsAfterTravel {
items.append(ItineraryRowItem.customItem(item))
}
}
// Custom items at start of day
let itemsAtStart = customItems.filter {
$0.anchorDay == dayNum && $0.anchorType == .startOfDay
}.sorted { $0.sortOrder < $1.sortOrder }
for item in itemsAtStart {
items.append(ItineraryRowItem.customItem(item))
}
// Custom items after game
if let lastGame = gamesOnDay.last {
let itemsAfterGame = customItems.filter {
$0.anchorDay == dayNum && $0.anchorType == .afterGame && $0.anchorId == lastGame.game.id
}.sorted { $0.sortOrder < $1.sortOrder }
for item in itemsAfterGame {
items.append(ItineraryRowItem.customItem(item))
}
}
// ONE Add button per day - after the last thing (game > travel > rest day)
if let lastGame = gamesOnDay.last {
items.append(ItineraryRowItem.addButton(day: dayNum, anchorType: .afterGame, anchorId: lastGame.game.id))
} else if let travelSegment = travelBefore {
let travelId = stableTravelAnchorId(travelSegment)
items.append(ItineraryRowItem.addButton(day: dayNum, anchorType: .afterTravel, anchorId: travelId))
} else {
items.append(ItineraryRowItem.addButton(day: dayNum, anchorType: .startOfDay, anchorId: nil))
}
let dayData = ItineraryDayData(
id: dayNum,
dayNumber: dayNum,
date: dayDate,
games: gamesOnDay,
items: items,
travelBefore: travelBefore
)
days.append(dayData)
}
return (days, travelValidRanges)
}
// MARK: - Helper Methods
private func calculateTripDays() -> [Date] {
let start = trip.startDate
let end = trip.endDate
var days: [Date] = []
var current = Calendar.current.startOfDay(for: start)
let endDay = Calendar.current.startOfDay(for: end)
while current <= endDay {
days.append(current)
current = Calendar.current.date(byAdding: .day, value: 1, to: current) ?? current
}
return days
}
private func gamesOn(date: Date) -> [RichGame] {
let calendar = Calendar.current
return games.filter { calendar.isDate($0.game.dateTime, inSameDayAs: date) }
.sorted { $0.game.dateTime < $1.game.dateTime }
}
private func stableTravelAnchorId(_ segment: TravelSegment) -> String {
"travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
}
private func findLastGameDay(in city: String, tripDays: [Date]) -> Int {
var lastDay = 0
for (index, dayDate) in tripDays.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { cityMatches($0.stadium.city, searchCity: city) }) {
lastDay = dayNum
}
}
return lastDay
}
private func findFirstGameDay(in city: String, tripDays: [Date]) -> Int {
for (index, dayDate) in tripDays.enumerated() {
let dayNum = index + 1
let gamesOnDay = gamesOn(date: dayDate)
if gamesOnDay.contains(where: { cityMatches($0.stadium.city, searchCity: city) }) {
return dayNum
}
}
return tripDays.count
}
/// Fuzzy city matching - handles "Salt Lake City" vs "Salt Lake" etc.
private func cityMatches(_ stadiumCity: String, searchCity: String) -> Bool {
let stadiumLower = stadiumCity.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
let searchLower = searchCity.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
// Exact match
if stadiumLower == searchLower {
return true
}
// One contains the other
if stadiumLower.contains(searchLower) || searchLower.contains(stadiumLower) {
return true
}
// Word-based matching (handles "Salt Lake" matching "Salt Lake City")
let stadiumWords = Set(stadiumLower.components(separatedBy: .whitespaces).filter { !$0.isEmpty })
let searchWords = Set(searchLower.components(separatedBy: .whitespaces).filter { !$0.isEmpty })
if !searchWords.isEmpty && searchWords.isSubset(of: stadiumWords) {
return true
}
if !stadiumWords.isEmpty && stadiumWords.isSubset(of: searchWords) {
return true
}
return false
}
}