Files
Sportstime/SportsTime/Features/Trip/Views/ItineraryTableViewController.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

710 lines
25 KiB
Swift

//
// 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<Int>] = [:] // 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<Int>]) {
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..<flatItems.count {
if case .dayHeader(let dayNum, _, _) = flatItems[i] {
return dayNum
}
}
// If no header found after, return last day
for i in stride(from: flatItems.count - 1, through: 0, by: -1) {
if case .dayHeader(let dayNum, _, _) = flatItems[i] {
return dayNum
}
}
return 1
}
override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> 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..<flatItems.count {
if case .dayHeader = flatItems[i] {
return IndexPath(row: i + 1, section: 0)
}
}
}
return IndexPath(row: proposedRow, section: 0)
default:
return sourceIndexPath
}
}
/// Find which day travel would belong to if placed at the proposed row
private func dayForTravelAtProposed(row: Int, excluding: Int) -> 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..<flatItems.count {
if i == excluding { continue }
if case .dayHeader(let dayNum, _, _) = flatItems[i] {
return dayNum
}
}
// If nothing found, use the last day
for i in stride(from: flatItems.count - 1, through: 0, by: -1) {
if i == excluding { continue }
if case .dayHeader(let dayNum, _, _) = flatItems[i] {
return dayNum
}
}
return 1
}
// MARK: - Editing Style
override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> 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?) {
if row > 0 {
let previousItem = flatItems[row - 1]
switch previousItem {
case .travel(let segment, _):
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
return (.afterTravel, travelId)
case .dayHeader(_, _, let games):
if let lastGame = games.last {
return (.afterGame, lastGame.game.id)
}
return (.startOfDay, nil)
default:
return (.startOfDay, nil)
}
}
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)
Text(item.title)
.font(.subheadline)
.foregroundStyle(Theme.textPrimary(colorScheme))
.lineLimit(2)
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)
}
}