// // 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, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) 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, let anchorType, let anchorId): return "add:\(day)-\(anchorType.rawValue)-\(anchorId ?? "nil")" } } 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, CustomItineraryItem.AnchorType, String?) -> Void)? var onCustomItemTapped: ((CustomItineraryItem) -> Void)? var onCustomItemDeleted: ((CustomItineraryItem) -> Void)? var onAddButtonTapped: ((Int, CustomItineraryItem.AnchorType, String?) -> Void)? // 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 anchorType, let anchorId): let cell = tableView.dequeueReusableCell(withIdentifier: addButtonCellId, for: indexPath) configureAddButtonCell(cell, day: day, anchorType: anchorType, anchorId: anchorId) 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 (anchorType, anchorId) = determineAnchor(at: destinationIndexPath.row) onCustomItemMoved?(customItem.id, destinationDay, anchorType, anchorId) 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, let anchorType, let anchorId): onAddButtonTapped?(day, anchorType, anchorId) 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 private func determineAnchor(at row: Int) -> (CustomItineraryItem.AnchorType, String?) { // Scan backwards to find the day's context // Structure: travel (optional) -> dayHeader -> items var foundTravel: TravelSegment? var foundDayGames: [RichGame] = [] for i in stride(from: row - 1, through: 0, by: -1) { switch flatItems[i] { case .travel(let segment, _): // Found travel - if this is the first significant item, use afterTravel // But only if we haven't passed a day header yet if foundDayGames.isEmpty { foundTravel = segment } // Travel marks the boundary - stop scanning let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" // If the drop is right after travel (no day header between), use afterTravel if foundDayGames.isEmpty { return (.afterTravel, travelId) } // Otherwise we already passed a day header, use that context break case .dayHeader(_, _, let games): // Found the day header for this section foundDayGames = games // If day has games, items dropped after should be afterGame if let lastGame = games.last { return (.afterGame, lastGame.game.id) } // No games - check if there's travel before this day // Continue scanning to find travel continue case .customItem, .addButton: // Skip these, keep scanning backwards continue } } // If we found travel but no games, use afterTravel if let segment = foundTravel { let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" return (.afterTravel, travelId) } return (.startOfDay, nil) } // MARK: - Cell Configuration private func configureDayHeaderCell(_ cell: UITableViewCell, dayNumber: Int, date: Date, games: [RichGame]) { cell.contentConfiguration = UIHostingConfiguration { DaySectionHeaderView(dayNumber: dayNumber, date: date, games: games, colorScheme: colorScheme) } .margins(.all, 0) .background(.clear) cell.backgroundColor = .clear cell.selectionStyle = .none } private func configureTravelCell(_ cell: UITableViewCell, segment: TravelSegment) { cell.contentConfiguration = UIHostingConfiguration { TravelRowView(segment: segment, colorScheme: colorScheme) } .margins(.all, 0) .background(.clear) cell.backgroundColor = .clear cell.selectionStyle = .none } private func configureCustomItemCell(_ cell: UITableViewCell, item: CustomItineraryItem) { cell.contentConfiguration = UIHostingConfiguration { CustomItemRowView(item: item, colorScheme: colorScheme) } .margins(.all, 0) .background(.clear) cell.backgroundColor = .clear cell.selectionStyle = .default } private func configureAddButtonCell(_ cell: UITableViewCell, day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) { cell.contentConfiguration = UIHostingConfiguration { AddButtonRowView(colorScheme: colorScheme) } .margins(.all, 0) .background(.clear) cell.backgroundColor = .clear cell.selectionStyle = .default } } // MARK: - SwiftUI Row Views struct DaySectionHeaderView: View { let dayNumber: Int let date: Date let games: [RichGame] let colorScheme: ColorScheme private var formattedDate: String { let formatter = DateFormatter() formatter.dateFormat = "EEEE, MMM d" return formatter.string(from: date) } var body: some View { VStack(alignment: .leading, spacing: Theme.Spacing.sm) { // Simple text header - no card styling HStack(alignment: .firstTextBaseline) { Text("Day \(dayNumber)") .font(.title3) .fontWeight(.bold) .foregroundStyle(Theme.textPrimary(colorScheme)) Text("·") .foregroundStyle(Theme.textMuted(colorScheme)) Text(formattedDate) .font(.subheadline) .foregroundStyle(Theme.textSecondary(colorScheme)) Spacer() if let firstGame = games.first { Text(firstGame.stadium.city) .font(.subheadline) .foregroundStyle(Theme.warmOrange) } } // Games as cards below the header ForEach(games, id: \.game.id) { richGame in GameRowCompact(richGame: richGame, colorScheme: colorScheme) } } .padding(.horizontal, Theme.Spacing.lg) .padding(.top, Theme.Spacing.lg) .padding(.bottom, Theme.Spacing.xs) } } struct GameRowCompact: View { let richGame: RichGame let colorScheme: ColorScheme private var formattedTime: String { let formatter = DateFormatter() formatter.dateFormat = "h:mm a" return formatter.string(from: richGame.game.dateTime) } var body: some View { HStack(spacing: Theme.Spacing.md) { // Sport color bar - taller RoundedRectangle(cornerRadius: 3) .fill(richGame.game.sport.color) .frame(width: 5) VStack(alignment: .leading, spacing: 6) { // Sport badge Text(richGame.game.sport.rawValue) .font(.caption) .fontWeight(.bold) .foregroundStyle(richGame.game.sport.color) // Matchup - larger and bolder Text(richGame.matchupDescription) .font(.body) .fontWeight(.semibold) .foregroundStyle(Theme.textPrimary(colorScheme)) // Stadium HStack(spacing: 6) { Image(systemName: "building.2") .font(.caption) Text(richGame.stadium.name) .font(.subheadline) } .foregroundStyle(Theme.textSecondary(colorScheme)) } Spacer() // Time - larger Text(formattedTime) .font(.title3) .fontWeight(.medium) .foregroundStyle(Theme.warmOrange) } .padding(Theme.Spacing.md) .background(Theme.cardBackgroundElevated(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: 12)) } } struct TravelRowView: View { let segment: TravelSegment let colorScheme: ColorScheme var body: some View { HStack(spacing: Theme.Spacing.md) { // Car icon ZStack { Circle() .fill(Theme.warmOrange.opacity(0.15)) .frame(width: 40, height: 40) Image(systemName: "car.fill") .font(.body) .foregroundStyle(Theme.warmOrange) } // Vertical stack: route on top, details below VStack(alignment: .leading, spacing: 4) { Text(segment.fromLocation.name) .font(.subheadline) .fontWeight(.medium) .foregroundStyle(Theme.textPrimary(colorScheme)) HStack(spacing: 4) { Image(systemName: "arrow.down") .font(.caption2) .foregroundStyle(Theme.textMuted(colorScheme)) Text("\(segment.formattedDistance) · \(segment.formattedDuration)") .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) } Text(segment.toLocation.name) .font(.subheadline) .fontWeight(.medium) .foregroundStyle(Theme.textPrimary(colorScheme)) } Spacer() } .padding(Theme.Spacing.md) .background(Theme.cardBackground(colorScheme)) .clipShape(RoundedRectangle(cornerRadius: 12)) .padding(.horizontal, Theme.Spacing.lg) .padding(.vertical, Theme.Spacing.xs) } } struct CustomItemRowView: View { let item: CustomItineraryItem let colorScheme: ColorScheme var body: some View { HStack(spacing: Theme.Spacing.sm) { Text(item.category.icon) .font(.title3) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 4) { Text(item.title) .font(.subheadline) .foregroundStyle(Theme.textPrimary(colorScheme)) .lineLimit(1) // Map pin for mappable items if item.isMappable { Image(systemName: "mappin.circle.fill") .font(.caption) .foregroundStyle(Theme.warmOrange) } } // Address subtitle for mappable items if let address = item.address, !address.isEmpty { Text(address) .font(.caption) .foregroundStyle(Theme.textMuted(colorScheme)) .lineLimit(1) } } Spacer() Image(systemName: "chevron.right") .foregroundStyle(.tertiary) .font(.caption) } .padding(.horizontal, Theme.Spacing.md) .padding(.vertical, Theme.Spacing.sm) .background { RoundedRectangle(cornerRadius: 8) .fill(Theme.cardBackground(colorScheme)) RoundedRectangle(cornerRadius: 8) .fill(Theme.warmOrange.opacity(0.1)) } .overlay { RoundedRectangle(cornerRadius: 8) .strokeBorder(Theme.warmOrange.opacity(0.3), lineWidth: 1) } .padding(.horizontal, Theme.Spacing.lg) .padding(.vertical, Theme.Spacing.xs) } } struct AddButtonRowView: View { let colorScheme: ColorScheme var body: some View { HStack(spacing: Theme.Spacing.xs) { Image(systemName: "plus.circle.fill") .foregroundStyle(Theme.warmOrange.opacity(0.6)) Text("Add") .font(.subheadline) .foregroundStyle(Theme.textMuted(colorScheme)) } .padding(.horizontal, Theme.Spacing.lg) .padding(.vertical, Theme.Spacing.sm) } }