Merge Add button into DaySectionHeaderView to prevent items from being dragged between the day header and Add button. The Add button now uses a SwiftUI Button with its own tap handler instead of row selection. Changes: - Remove .addButton case from ItineraryRowItem enum - Update DaySectionHeaderView to include Add button on the right - Pass onAddTapped callback through configureDayHeaderCell - Remove AddButtonRowView (no longer needed) - Update documentation to reflect new row structure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
266 lines
10 KiB
Swift
266 lines
10 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, Double) -> Void)? // itemId, newDay, newSortOrder
|
|
var onCustomItemTapped: ((CustomItineraryItem) -> Void)?
|
|
var onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
|
|
var onAddButtonTapped: ((Int) -> Void)? // Just day number
|
|
|
|
init(
|
|
trip: Trip,
|
|
games: [RichGame],
|
|
customItems: [CustomItineraryItem],
|
|
travelDayOverrides: [String: Int],
|
|
@ViewBuilder headerContent: () -> HeaderContent,
|
|
onTravelMoved: ((String, Int) -> Void)? = nil,
|
|
onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil,
|
|
onCustomItemTapped: ((CustomItineraryItem) -> Void)? = nil,
|
|
onCustomItemDeleted: ((CustomItineraryItem) -> Void)? = nil,
|
|
onAddButtonTapped: ((Int) -> 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 makeCoordinator() -> Coordinator {
|
|
Coordinator()
|
|
}
|
|
|
|
class Coordinator {
|
|
var headerHostingController: UIHostingController<HeaderContent>?
|
|
}
|
|
|
|
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
|
|
|
|
// Store in coordinator for later updates
|
|
context.coordinator.headerHostingController = hostingController
|
|
|
|
// 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
|
|
|
|
// Update header content by updating the hosting controller's rootView
|
|
// This avoids recreating the view hierarchy and prevents infinite loops
|
|
context.coordinator.headerHostingController?.rootView = headerContent
|
|
|
|
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 for this day - simply filter by day and sort by sortOrder
|
|
// Note: Add button is now embedded in the day header row (not a separate item)
|
|
let dayItems = customItems.filter { $0.day == dayNum }
|
|
.sorted { $0.sortOrder < $1.sortOrder }
|
|
|
|
for item in dayItems {
|
|
items.append(ItineraryRowItem.customItem(item))
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|