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>
This commit is contained in:
@@ -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<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)
|
||||
}
|
||||
}
|
||||
286
SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift
Normal file
286
SportsTime/Features/Trip/Views/ItineraryTableViewWrapper.swift
Normal file
@@ -0,0 +1,286 @@
|
||||
//
|
||||
// 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, 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<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 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user