// // ItineraryTableViewController.swift // SportsTime // // Native UITableViewController for fluid itinerary reordering // import UIKit import SwiftUI // MARK: - Data Models /// Represents a single day in the itinerary struct ItineraryDayData: Identifiable { let id: Int // dayNumber let dayNumber: Int let date: Date let games: [RichGame] var items: [ItineraryRowItem] // Items that appear AFTER the day header var travelBefore: TravelSegment? // Travel that appears BEFORE this day's header var isRestDay: Bool { games.isEmpty } } /// Represents a row item in the itinerary enum ItineraryRowItem: Identifiable, Equatable { case dayHeader(dayNumber: Int, date: Date, games: [RichGame]) case travel(TravelSegment, dayNumber: Int) // dayNumber = the day this travel is associated with case customItem(CustomItineraryItem) case addButton(day: Int) // Simplified - just needs day var id: String { switch self { case .dayHeader(let dayNumber, _, _): return "day:\(dayNumber)" case .travel(let segment, _): return "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" case .customItem(let item): return "item:\(item.id.uuidString)" case .addButton(let day): return "add:\(day)" } } var isReorderable: Bool { switch self { case .dayHeader, .addButton: return false case .travel, .customItem: return true } } static func == (lhs: ItineraryRowItem, rhs: ItineraryRowItem) -> Bool { lhs.id == rhs.id } } // MARK: - Table View Controller final class ItineraryTableViewController: UITableViewController { // MARK: - Properties private var flatItems: [ItineraryRowItem] = [] var travelValidRanges: [String: ClosedRange] = [:] // travelId -> valid day range var colorScheme: ColorScheme = .dark // Callbacks var onTravelMoved: ((String, Int) -> Void)? // travelId, newDay var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder var onCustomItemTapped: ((CustomItineraryItem) -> Void)? var onCustomItemDeleted: ((CustomItineraryItem) -> Void)? var onAddButtonTapped: ((Int) -> Void)? // Just day number // Cell reuse identifiers private let dayHeaderCellId = "DayHeaderCell" private let travelCellId = "TravelCell" private let customItemCellId = "CustomItemCell" private let addButtonCellId = "AddButtonCell" // Header sizing state - prevents infinite layout loops private var lastHeaderHeight: CGFloat = 0 private var isAdjustingHeader = false // MARK: - Lifecycle override func viewDidLoad() { super.viewDidLoad() setupTableView() } private func setupTableView() { tableView.backgroundColor = .clear tableView.separatorStyle = .none tableView.rowHeight = UITableView.automaticDimension tableView.estimatedRowHeight = 80 tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 40, right: 0) // Enable editing mode for reordering tableView.isEditing = true tableView.allowsSelectionDuringEditing = true // Register cells tableView.register(UITableViewCell.self, forCellReuseIdentifier: dayHeaderCellId) tableView.register(UITableViewCell.self, forCellReuseIdentifier: travelCellId) tableView.register(UITableViewCell.self, forCellReuseIdentifier: customItemCellId) tableView.register(UITableViewCell.self, forCellReuseIdentifier: addButtonCellId) } func setTableHeader(_ view: UIView) { // Wrap in a container to help with sizing let containerView = UIView() containerView.backgroundColor = .clear containerView.addSubview(view) view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ view.topAnchor.constraint(equalTo: containerView.topAnchor), view.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), view.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), view.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) ]) tableView.tableHeaderView = containerView } override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() // Prevent infinite loops guard !isAdjustingHeader else { return } guard let headerView = tableView.tableHeaderView else { return } guard tableView.bounds.width > 0 else { return } let targetSize = CGSize(width: tableView.bounds.width, height: UIView.layoutFittingCompressedSize.height) let size = headerView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel) // Only update if height changed significantly (avoid floating-point jitter) let heightDelta = abs(headerView.frame.size.height - size.height) if heightDelta > 1 && abs(size.height - lastHeaderHeight) > 1 { isAdjustingHeader = true lastHeaderHeight = size.height headerView.frame.size.height = size.height tableView.tableHeaderView = headerView isAdjustingHeader = false } } func reloadData(days: [ItineraryDayData], travelValidRanges: [String: ClosedRange]) { self.travelValidRanges = travelValidRanges // Build flat list: for each day, travel BEFORE header, then header, then items flatItems = [] for day in days { // Travel that arrives on this day (appears BEFORE the day header) if let travel = day.travelBefore { flatItems.append(.travel(travel, dayNumber: day.dayNumber)) } // Day header (with games inside) flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date, games: day.games)) // Items for this day (custom items, add buttons) for item in day.items { flatItems.append(item) } } tableView.reloadData() } // MARK: - Helper to find day for row private func dayNumber(forRow row: Int) -> Int { // Scan backwards to find the nearest day header for i in stride(from: row, through: 0, by: -1) { if case .dayHeader(let dayNum, _, _) = flatItems[i] { return dayNum } // If we hit travel, it belongs to the NEXT day header if case .travel(_, let dayNum) = flatItems[i] { return dayNum } } return 1 } private func dayHeaderRow(forDay day: Int) -> Int? { for (index, item) in flatItems.enumerated() { if case .dayHeader(let dayNum, _, _) = item, dayNum == day { return index } } return nil } private func travelRow(forDay day: Int) -> Int? { for (index, item) in flatItems.enumerated() { if case .travel(_, let dayNum) = item, dayNum == day { return index } } return nil } // MARK: - UITableViewDataSource override func numberOfSections(in tableView: UITableView) -> Int { return 1 } override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { return flatItems.count } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { let item = flatItems[indexPath.row] switch item { case .dayHeader(let dayNumber, let date, let games): let cell = tableView.dequeueReusableCell(withIdentifier: dayHeaderCellId, for: indexPath) configureDayHeaderCell(cell, dayNumber: dayNumber, date: date, games: games) return cell case .travel(let segment, _): let cell = tableView.dequeueReusableCell(withIdentifier: travelCellId, for: indexPath) configureTravelCell(cell, segment: segment) return cell case .customItem(let customItem): let cell = tableView.dequeueReusableCell(withIdentifier: customItemCellId, for: indexPath) configureCustomItemCell(cell, item: customItem) return cell case .addButton(let day): let cell = tableView.dequeueReusableCell(withIdentifier: addButtonCellId, for: indexPath) configureAddButtonCell(cell, day: day) return cell } } // MARK: - Reordering override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { return flatItems[indexPath.row].isReorderable } override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { let item = flatItems[sourceIndexPath.row] // Remove from source flatItems.remove(at: sourceIndexPath.row) // Insert at destination flatItems.insert(item, at: destinationIndexPath.row) // Notify callbacks switch item { case .travel(let segment, _): // Find which day this travel is now associated with let newDay = dayForTravelAt(row: destinationIndexPath.row) let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" onTravelMoved?(travelId, newDay) case .customItem(let customItem): let destinationDay = dayNumber(forRow: destinationIndexPath.row) let sortOrder = calculateSortOrder(at: destinationIndexPath.row) onCustomItemMoved?(customItem.id, destinationDay, sortOrder) default: break } } /// For travel, find the day it's now before (the next day header after this position) private func dayForTravelAt(row: Int) -> Int { for i in row.. IndexPath { let item = flatItems[sourceIndexPath.row] var proposedRow = proposedDestinationIndexPath.row // Can't move to position 0 (before everything) if proposedRow == 0 { proposedRow = 1 } // Ensure proposedRow is in bounds proposedRow = min(proposedRow, flatItems.count - 1) switch item { case .travel(let segment, _): let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" guard let validRange = travelValidRanges[travelId] else { print("⚠️ No valid range for travel: \(travelId)") return proposedDestinationIndexPath } // Find which day travel would be associated with at proposed position // Travel is associated with the NEXT day header after its position var proposedDay = dayForTravelAtProposed(row: proposedRow, excluding: sourceIndexPath.row) print("🎯 Drag travel: proposedRow=\(proposedRow), proposedDay=\(proposedDay), validRange=\(validRange)") // Constrain to valid range if proposedDay < validRange.lowerBound { print("🎯 Clamping up: \(proposedDay) -> \(validRange.lowerBound)") proposedDay = validRange.lowerBound } else if proposedDay > validRange.upperBound { print("🎯 Clamping down: \(proposedDay) -> \(validRange.upperBound)") proposedDay = validRange.upperBound } // Find the correct row: right before the day header for proposedDay if let headerRow = dayHeaderRow(forDay: proposedDay) { // Travel goes right before the day header // But account for the fact that we're removing from source first var targetRow = headerRow if sourceIndexPath.row < headerRow { targetRow -= 1 // Source removal shifts everything up } print("🎯 Final target: day=\(proposedDay), headerRow=\(headerRow), targetRow=\(targetRow)") return IndexPath(row: max(0, targetRow), section: 0) } return proposedDestinationIndexPath case .customItem: // Custom items can go anywhere except: // - Before position 0 // - Onto a day header (go after it instead) if proposedRow < flatItems.count, case .dayHeader = flatItems[proposedRow] { return IndexPath(row: proposedRow + 1, section: 0) } // Can't go before travel that's at the start if proposedRow < flatItems.count, case .travel = flatItems[proposedRow] { // Find the day header after this travel and go after it for i in proposedRow.. Int { // Look forward from the proposed row to find the next day header // Skip the excluded row (the item being moved) for i in row.. UITableViewCell.EditingStyle { return .none } override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool { return false } // MARK: - Selection override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { tableView.deselectRow(at: indexPath, animated: true) let item = flatItems[indexPath.row] switch item { case .customItem(let customItem): onCustomItemTapped?(customItem) case .addButton(let day): onAddButtonTapped?(day) default: break } } // MARK: - Context Menu override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { let item = flatItems[indexPath.row] guard case .customItem(let customItem) = item else { return nil } return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in let editAction = UIAction(title: "Edit", image: UIImage(systemName: "pencil")) { [weak self] _ in self?.onCustomItemTapped?(customItem) } let deleteAction = UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) { [weak self] _ in self?.onCustomItemDeleted?(customItem) } return UIMenu(title: "", children: [editAction, deleteAction]) } } // MARK: - Helper Methods /// Calculate the sortOrder for an item dropped at the given row position /// Uses midpoint insertion: if between sortOrder 1.0 and 2.0, returns 1.5 private func calculateSortOrder(at row: Int) -> Double { // Find adjacent custom items to calculate midpoint var prevSortOrder: Double? var nextSortOrder: Double? // Scan backwards for previous custom item in same day for i in stride(from: row - 1, through: 0, by: -1) { switch flatItems[i] { case .customItem(let item): prevSortOrder = item.sortOrder break case .dayHeader, .travel: // Hit a boundary - no previous item in this section break case .addButton: continue } if prevSortOrder != nil { break } // If we hit dayHeader or travel, stop scanning if case .dayHeader = flatItems[i] { break } if case .travel = flatItems[i] { break } } // Scan forwards for next custom item in same day for i in row..