diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift new file mode 100644 index 0000000..17c4373 --- /dev/null +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -0,0 +1,709 @@ +// +// 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?) { + 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) + } +} diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift new file mode 100644 index 0000000..0e80c6a --- /dev/null +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift @@ -0,0 +1,286 @@ +// +// ItineraryTableViewWrapper.swift +// SportsTime +// +// UIViewControllerRepresentable wrapper for ItineraryTableViewController +// + +import SwiftUI + +struct ItineraryTableViewWrapper: 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]) { + let tripDays = calculateTripDays() + var travelValidRanges: [String: ClosedRange] = [:] + + // 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 + } +} diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 81f1617..c426b15 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -80,40 +80,7 @@ struct TripDetailView: View { } var body: some View { - ScrollView { - VStack(spacing: 0) { - // Hero Map - heroMapSection - .frame(height: 280) - - // Content - VStack(spacing: Theme.Spacing.lg) { - // Header - tripHeader - .padding(.top, Theme.Spacing.lg) - - // Stats Row - statsRow - - // Score Card - if let score = trip.score { - scoreCard(score) - } - - // Day-by-Day Itinerary - itinerarySection - } - .padding(.horizontal, Theme.Spacing.lg) - .padding(.bottom, Theme.Spacing.xxl) - } - // Catch-all drop handler - clears drag state and accepts the drop to end drag - .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in - draggedTravelId = nil - draggedItem = nil - dropTargetId = nil - return true // Accept the drop to end the drag operation cleanly - } - } + mainContent .background(Theme.backgroundGradient(colorScheme)) .toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar) .toolbar { @@ -200,6 +167,102 @@ struct TripDetailView: View { } } + // MARK: - Main Content + + @ViewBuilder + private var mainContent: some View { + if allowCustomItems { + // Full-screen table with map as header + ItineraryTableViewWrapper( + trip: trip, + games: Array(games.values), + customItems: customItems, + travelDayOverrides: travelDayOverrides, + headerContent: { + VStack(spacing: 0) { + // Hero Map + heroMapSection + .frame(height: 280) + + // Content header + VStack(spacing: Theme.Spacing.lg) { + tripHeader + .padding(.top, Theme.Spacing.lg) + + statsRow + + if let score = trip.score { + scoreCard(score) + } + + // Itinerary title + Text("Itinerary") + .font(.title2) + .fontWeight(.bold) + .foregroundStyle(Theme.textPrimary(colorScheme)) + .frame(maxWidth: .infinity, alignment: .leading) + } + .padding(.horizontal, Theme.Spacing.lg) + .padding(.bottom, Theme.Spacing.md) + } + }, + onTravelMoved: { travelId, newDay in + Task { @MainActor in + withAnimation { + travelDayOverrides[travelId] = newDay + } + await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay) + } + }, + onCustomItemMoved: { itemId, day, anchorType, anchorId in + Task { @MainActor in + guard let item = customItems.first(where: { $0.id == itemId }) else { return } + await moveItemToBeginning(item, toDay: day, anchorType: anchorType, anchorId: anchorId) + } + }, + onCustomItemTapped: { item in + editingItem = item + }, + onCustomItemDeleted: { item in + Task { await deleteCustomItem(item) } + }, + onAddButtonTapped: { day, anchorType, anchorId in + addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId) + } + ) + .ignoresSafeArea(edges: .bottom) + } else { + // Non-editable scroll view for unsaved trips + ScrollView { + VStack(spacing: 0) { + heroMapSection + .frame(height: 280) + + VStack(spacing: Theme.Spacing.lg) { + tripHeader + .padding(.top, Theme.Spacing.lg) + + statsRow + + if let score = trip.score { + scoreCard(score) + } + + itinerarySection + } + .padding(.horizontal, Theme.Spacing.lg) + .padding(.bottom, Theme.Spacing.xxl) + } + .onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in + draggedTravelId = nil + draggedItem = nil + dropTargetId = nil + return true + } + } + } + } + // MARK: - Export Progress Overlay private var exportProgressOverlay: some View { @@ -391,7 +454,7 @@ struct TripDetailView: View { } } - // MARK: - Itinerary + // MARK: - Itinerary (for non-editable scroll view) private var itinerarySection: some View { VStack(alignment: .leading, spacing: Theme.Spacing.md) { @@ -407,15 +470,13 @@ struct TripDetailView: View { Spacer() } } else { - // ZStack with continuous vertical line behind all content + // Non-editable view for non-saved trips ZStack(alignment: .top) { - // Continuous vertical line down the center Rectangle() .fill(Theme.routeGold.opacity(0.4)) .frame(width: 2) .frame(maxHeight: .infinity) - // Itinerary content VStack(spacing: Theme.Spacing.md) { ForEach(Array(itinerarySections.enumerated()), id: \.offset) { index, section in itineraryRow(for: section, at: index)