Include segment index in travel anchor IDs ("travel:INDEX:from->to")
so Follow Team trips visiting the same city pair multiple times get
unique, independently addressable travel segments. Prevents override
dictionary collisions and incorrect validDayRange lookups.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1383 lines
63 KiB
Swift
1383 lines
63 KiB
Swift
//
|
||
// ItineraryTableViewController.swift
|
||
// SportsTime
|
||
//
|
||
// Native UITableViewController for fluid itinerary drag-and-drop reordering.
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// ARCHITECTURE OVERVIEW
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// WHY UITableViewController INSTEAD OF SWIFTUI?
|
||
// ─────────────────────────────────────────────
|
||
// SwiftUI's drag-and-drop APIs (`.draggable()`, `.dropDestination()`) have significant
|
||
// limitations for complex reordering:
|
||
//
|
||
// 1. No real-time visual feedback during drag (item snaps only on drop)
|
||
// 2. Limited control over drop target validation during drag
|
||
// 3. Difficult to constrain items to valid drop zones while dragging
|
||
// 4. ForEach with onMove doesn't support heterogeneous item types well
|
||
//
|
||
// UITableViewController provides:
|
||
// - Native drag handles with familiar iOS reordering UX
|
||
// - Real-time row shifting during drag via `targetIndexPathForMoveFromRowAt`
|
||
// - Fine-grained control over valid drop positions
|
||
// - Built-in animation and haptic feedback
|
||
//
|
||
// The trade-off: We use UIHostingConfiguration to render SwiftUI views in cells,
|
||
// getting UIKit's interaction model with SwiftUI's declarative UI.
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// DATA FLOW
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// ┌─────────────────────────────────────────────────────────────────────────────┐
|
||
// │ TripDetailView (SwiftUI) │
|
||
// │ - Owns trip data, custom items, travel day overrides │
|
||
// │ - Computes itinerary sections for non-table display │
|
||
// └──────────────────────────────────┬──────────────────────────────────────────┘
|
||
// │
|
||
// ▼
|
||
// ┌─────────────────────────────────────────────────────────────────────────────┐
|
||
// │ ItineraryTableViewWrapper (SwiftUI) │
|
||
// │ - UIViewControllerRepresentable bridge │
|
||
// │ - buildItineraryData() transforms trip → [ItineraryDayData] │
|
||
// │ - Computes travelValidRanges for each travel segment │
|
||
// │ - Passes callbacks for move/tap/delete events │
|
||
// └──────────────────────────────────┬──────────────────────────────────────────┘
|
||
// │
|
||
// ▼
|
||
// ┌─────────────────────────────────────────────────────────────────────────────┐
|
||
// │ ItineraryTableViewController (UIKit) │
|
||
// │ - reloadData() flattens [ItineraryDayData] → [ItineraryRowItem] │
|
||
// │ - Renders rows, handles drag-and-drop, fires callbacks │
|
||
// └─────────────────────────────────────────────────────────────────────────────┘
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// ROW ORDERING (FLATTENING STRATEGY)
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// Each day is flattened into rows in this EXACT order:
|
||
//
|
||
// 1. TRAVEL (if arriving this day) - "Detroit → Milwaukee" card
|
||
// 2. DAY HEADER + ADD BUTTON - "Day 3 · Sunday, Mar 8 + Add"
|
||
// 3. GAMES - All games for this day (city label + cards)
|
||
// 4. CUSTOM ITEMS - User-added items sorted by sortOrder
|
||
//
|
||
// NOTE: The Add button is EMBEDDED in the day header row (not a separate row).
|
||
// This prevents items from being dragged between the header and Add button.
|
||
//
|
||
// Visual example:
|
||
// ┌─────────────────────────────────────┐
|
||
// │ 🚗 Detroit → Milwaukee (327mi) │ ← Travel (arrives Day 3)
|
||
// ├─────────────────────────────────────┤
|
||
// │ Day 3 · Sunday, Mar 8 + Add │ ← Day Header with Add button
|
||
// ├─────────────────────────────────────┤
|
||
// │ Milwaukee │ ← Games (city + cards)
|
||
// │ ┌─────────────────────────────────┐ │
|
||
// │ │ NBA: ORL @ MIL 8:00 PM │ │
|
||
// │ └─────────────────────────────────┘ │
|
||
// ├─────────────────────────────────────┤
|
||
// │ 🍽️ Waffle House │ ← Custom Item (sortOrder: 1.0)
|
||
// │ 🍽️ Joe's BBQ │ ← Custom Item (sortOrder: 2.0)
|
||
// └─────────────────────────────────────┘
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// REORDERABLE VS FIXED ITEMS
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// REORDERABLE (user can drag):
|
||
// - Travel segments (constrained to valid day range)
|
||
// - Custom items (can move to any day, any position)
|
||
//
|
||
// FIXED (no drag handle):
|
||
// - Day headers with Add button (structural anchors)
|
||
// - Games (determined by trip planning, not user-movable)
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// TRAVEL MOVEMENT CONSTRAINTS
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// Travel segments have HARD constraints on which days they can occupy.
|
||
// This prevents impossible itineraries (e.g., arriving before leaving).
|
||
//
|
||
// Valid range calculation (in ItineraryTableViewWrapper.buildItineraryData):
|
||
//
|
||
// For travel "Detroit → Milwaukee":
|
||
// - EARLIEST day: day after last game in Detroit (must finish games first)
|
||
// - LATEST day: day of first game in Milwaukee (must arrive for game)
|
||
//
|
||
// Example: If Detroit has games on Days 1-2, Milwaukee has game on Day 4:
|
||
// - Valid range: Days 3-4 (can travel on Day 3 rest day, or Day 4 morning)
|
||
//
|
||
// During drag, `targetIndexPathForMoveFromRowAt` clamps the drop position
|
||
// to keep travel within its valid range. User sees travel "snap" to valid
|
||
// positions rather than being allowed in invalid spots.
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// SORT ORDER SYSTEM
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// Custom items use a Double sortOrder for positioning within a day.
|
||
// This allows unlimited insertions without integer collisions.
|
||
//
|
||
// MIDPOINT INSERTION ALGORITHM (calculateSortOrder):
|
||
//
|
||
// When dropping between two items:
|
||
// - Item A has sortOrder 1.0
|
||
// - Item B has sortOrder 2.0
|
||
// - Dropped item gets sortOrder 1.5 (midpoint)
|
||
//
|
||
// Edge cases:
|
||
// - Dropping first: sortOrder = existing_first / 2.0
|
||
// - Dropping last: sortOrder = existing_last + 1.0
|
||
// - Empty day: sortOrder = 1.0
|
||
//
|
||
// This allows unlimited reordering without needing to renumber all items.
|
||
// Even after many insertions, precision remains sufficient (Double has ~15
|
||
// significant digits, so you'd need millions of insertions to cause issues).
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// CALLBACKS TO PARENT
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// onTravelMoved: ((String, Int) -> Void)?
|
||
// - Called when user drops travel segment
|
||
// - Parameters: travelId (stable identifier), newDay (1-indexed)
|
||
// - Parent stores override in travelDayOverrides dictionary
|
||
//
|
||
// onCustomItemMoved: ((UUID, Int, Double) -> Void)?
|
||
// - Called when user drops custom item
|
||
// - Parameters: itemId, newDay (1-indexed), newSortOrder
|
||
// - Parent updates item and syncs to CloudKit
|
||
//
|
||
// onCustomItemTapped: ((ItineraryItem) -> Void)?
|
||
// - Called when user taps custom item row
|
||
// - Parent presents edit sheet
|
||
//
|
||
// onCustomItemDeleted: ((ItineraryItem) -> Void)?
|
||
// - Called from context menu delete action
|
||
// - Parent deletes from CloudKit
|
||
//
|
||
// onAddButtonTapped: ((Int) -> Void)?
|
||
// - Called when user taps Add button
|
||
// - Parameter: day number (1-indexed)
|
||
// - Parent presents AddItemSheet for that day
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// KEY IMPLEMENTATION DETAILS
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// 1. EDITING MODE ALWAYS ON
|
||
// tableView.isEditing = true enables drag handles without delete buttons.
|
||
// editingStyleForRowAt returns .none to hide delete controls.
|
||
//
|
||
// 2. HEADER SIZING
|
||
// Table header (trip summary card) uses dynamic sizing. The
|
||
// viewDidLayoutSubviews dance prevents infinite layout loops by tracking
|
||
// lastHeaderHeight and using isAdjustingHeader guard.
|
||
//
|
||
// 3. TRAVEL DAY ASSOCIATION
|
||
// Travel is conceptually "the travel that arrives on day N". Visually it
|
||
// appears BEFORE day N's header. When finding which day a travel belongs
|
||
// to, we look FORWARD to find the next dayHeader.
|
||
//
|
||
// 4. CONTEXT MENUS
|
||
// Long-press on custom items shows Edit/Delete menu via UIKit context menus.
|
||
// More reliable than SwiftUI's contextMenu in table cells.
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// LIMITATIONS & KNOWN ISSUES
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// 1. Games are not individually reorderable - they're bundled as one row per day.
|
||
// This is intentional: game times are fixed, reordering would be confusing.
|
||
//
|
||
// 2. Travel segments can't be deleted - they're structural to the trip route.
|
||
// User can only move them within valid day ranges.
|
||
//
|
||
// 3. sortOrder precision: After millions of midpoint insertions, precision
|
||
// could theoretically degrade. In practice, this never happens. If needed,
|
||
// a "normalize sort orders" function could reassign 1.0, 2.0, 3.0...
|
||
//
|
||
// 4. SwiftUI views in cells: UIHostingConfiguration works well but has slight
|
||
// overhead. For very long trips (100+ days), consider cell recycling optimization.
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
// RELATED FILES
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
//
|
||
// - ItineraryTableViewWrapper.swift: SwiftUI bridge, data transformation
|
||
// - TripDetailView.swift: Parent view, owns state, handles callbacks
|
||
// - ItineraryItem.swift: Domain model with (day, sortOrder, kind) positioning
|
||
// - ItineraryItemService.swift: CloudKit persistence for itinerary items
|
||
//
|
||
// ═══════════════════════════════════════════════════════════════════════════════
|
||
|
||
import UIKit
|
||
import SwiftUI
|
||
|
||
// MARK: - Data Models
|
||
|
||
/// Intermediate structure passed from ItineraryTableViewWrapper.
|
||
/// Contains one day's worth of data before flattening into rows.
|
||
///
|
||
/// Note: `items` contains only custom items (add buttons are generated during flattening).
|
||
/// `travelBefore` is the travel segment that ARRIVES on this day (visually before the header).
|
||
struct ItineraryDayData: Identifiable {
|
||
let id: Int // dayNumber (1-indexed)
|
||
let dayNumber: Int
|
||
let date: Date
|
||
let games: [RichGame]
|
||
var items: [ItineraryRowItem] // Custom items for this day (sorted by sortOrder)
|
||
var travelBefore: TravelSegment? // Travel arriving this day (renders BEFORE day header)
|
||
|
||
var isRestDay: Bool { games.isEmpty }
|
||
}
|
||
|
||
/// Represents a single row in the flattened table view.
|
||
///
|
||
/// The enum cases map 1:1 to visual row types. Each has a stable `id` for diffing
|
||
/// and an `isReorderable` flag controlling whether the drag handle appears.
|
||
///
|
||
/// ID format examples:
|
||
/// - dayHeader: "day:3"
|
||
/// - games: "games:3"
|
||
/// - travel: "travel:0:detroit->milwaukee" (index:lowercase, stable across sessions)
|
||
/// - customItem: "item:550e8400-e29b-41d4-a716-446655440000"
|
||
/// - addButton: "add:3"
|
||
enum ItineraryRowItem: Identifiable, Equatable {
|
||
case dayHeader(dayNumber: Int, date: Date) // Fixed: structural anchor (includes Add button)
|
||
case games([RichGame], dayNumber: Int) // Fixed: games are trip-determined
|
||
case travel(TravelSegment, dayNumber: Int) // Reorderable: within valid range
|
||
case customItem(ItineraryItem) // Reorderable: anywhere
|
||
|
||
/// Stable identifier for table view diffing and external references.
|
||
/// Travel IDs include segment index and are lowercase for consistency.
|
||
var id: String {
|
||
switch self {
|
||
case .dayHeader(let dayNumber, _):
|
||
return "day:\(dayNumber)"
|
||
case .games(_, let dayNumber):
|
||
return "games:\(dayNumber)"
|
||
case .travel(let segment, let dayNumber):
|
||
// Use segment UUID for unique diffing (segment index is not available here)
|
||
return "travel:\(segment.id.uuidString):\(dayNumber)"
|
||
case .customItem(let item):
|
||
return "item:\(item.id.uuidString)"
|
||
}
|
||
}
|
||
|
||
/// Controls whether UITableView shows the drag reorder handle.
|
||
/// Day headers and games are structural - users can't move them.
|
||
var isReorderable: Bool {
|
||
switch self {
|
||
case .dayHeader, .games:
|
||
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, Double) -> Void)? // travelId, newDay
|
||
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
||
var onCustomItemTapped: ((ItineraryItem) -> Void)?
|
||
var onCustomItemDeleted: ((ItineraryItem) -> Void)?
|
||
var onAddButtonTapped: ((Int) -> Void)? // Just day number
|
||
|
||
// Cell reuse identifiers
|
||
private let dayHeaderCellId = "DayHeaderCell"
|
||
private let gamesCellId = "GamesCell"
|
||
private let travelCellId = "TravelCell"
|
||
private let customItemCellId = "CustomItemCell"
|
||
|
||
// Header sizing state - prevents infinite layout loops
|
||
private var lastHeaderHeight: CGFloat = 0
|
||
private var isAdjustingHeader = false
|
||
|
||
// MARK: - Constraint-Aware Drag State
|
||
//
|
||
// These properties track the current drag operation for constraint validation
|
||
// and visual feedback. They're populated when drag starts and cleared when it ends.
|
||
|
||
/// The constraint system for validating item positions
|
||
private var constraints: ItineraryConstraints?
|
||
|
||
/// All itinerary items (needed to build constraints)
|
||
private var allItineraryItems: [ItineraryItem] = []
|
||
|
||
/// Trip day count for constraints
|
||
private var tripDayCount: Int = 0
|
||
|
||
/// The item currently being dragged (nil when no drag active)
|
||
private var draggingItem: ItineraryRowItem?
|
||
|
||
/// The day number the user is targeting during custom item drag (for stable positioning)
|
||
private var dragTargetDay: Int?
|
||
|
||
/// Row indices that are invalid drop targets for the current drag (for visual dimming)
|
||
private var invalidRowIndices: Set<Int> = []
|
||
|
||
/// Row indices that ARE valid drop targets - pre-calculated at drag start for stability
|
||
/// Using a sorted array enables O(log n) nearest-neighbor lookup
|
||
private var validDropRows: [Int] = []
|
||
|
||
/// Valid destination rows in *proposed* coordinate space (after removing the source row).
|
||
/// Precomputed at drag start by simulating the move and validating semantic constraints.
|
||
private var validDestinationRowsProposed: [Int] = []
|
||
|
||
|
||
/// IDs of games that act as barriers for the current travel drag (for gold highlighting)
|
||
private var barrierGameIds: Set<String> = []
|
||
|
||
/// Track whether we're currently in a valid drop zone (for haptic feedback)
|
||
private var isInValidZone: Bool = true
|
||
|
||
/// Haptic feedback generator for drag interactions
|
||
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .medium)
|
||
|
||
// MARK: - Lifecycle
|
||
|
||
override func viewDidLoad() {
|
||
super.viewDidLoad()
|
||
setupTableView()
|
||
}
|
||
|
||
/// Configures the UITableView for our specific use case.
|
||
///
|
||
/// Key decisions:
|
||
/// - `isEditing = true`: Shows drag handles on reorderable rows. We keep this permanently on
|
||
/// since the primary interaction IS reordering. Non-reorderable rows return false from
|
||
/// `canMoveRowAt` so they don't show handles.
|
||
/// - `allowsSelectionDuringEditing = true`: Normally, editing mode disables row selection.
|
||
/// We need selection for tapping Add buttons and custom items, so we override this.
|
||
/// - `separatorStyle = .none`: We use custom card styling per row type, so system separators
|
||
/// would look out of place.
|
||
/// - `estimatedRowHeight = 80`: Helps UITableView's internal calculations. The actual height
|
||
/// is determined by UIHostingConfiguration's automatic sizing.
|
||
/// - `contentInset.bottom = 40`: Adds padding at the bottom so the last row isn't flush
|
||
/// against the screen edge when scrolled to the end.
|
||
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)
|
||
|
||
// IMPORTANT: isEditing must be true for drag handles to appear.
|
||
// This is different from "delete mode" editing - we disable delete controls
|
||
// via editingStyleForRowAt returning .none.
|
||
tableView.isEditing = true
|
||
tableView.allowsSelectionDuringEditing = true
|
||
|
||
// We use generic UITableViewCell with UIHostingConfiguration for content.
|
||
// This gives us SwiftUI views with UIKit drag-and-drop. Different cell IDs
|
||
// help with debugging but aren't strictly necessary since we fully replace
|
||
// contentConfiguration each time.
|
||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: dayHeaderCellId)
|
||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: gamesCellId)
|
||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: travelCellId)
|
||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: customItemCellId)
|
||
}
|
||
|
||
/// Installs a SwiftUI view as the table's header (appears above all rows).
|
||
///
|
||
/// We wrap the view in a container because UITableView header sizing is notoriously
|
||
/// tricky. The container provides a consistent Auto Layout context. The actual sizing
|
||
/// is handled in `viewDidLayoutSubviews`.
|
||
///
|
||
/// - Parameter view: A UIView (typically from UIHostingController wrapping SwiftUI content)
|
||
func setTableHeader(_ view: UIView) {
|
||
let containerView = UIView()
|
||
containerView.backgroundColor = .clear
|
||
containerView.addSubview(view)
|
||
|
||
// Pin the content view to all edges of the container
|
||
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
|
||
}
|
||
|
||
/// Handles dynamic header sizing.
|
||
///
|
||
/// UITableView doesn't automatically size tableHeaderView - we must manually calculate
|
||
/// the appropriate height and update the frame. This is called on every layout pass.
|
||
///
|
||
/// **CRITICAL: Infinite Loop Prevention**
|
||
/// Setting `tableView.tableHeaderView = headerView` triggers another layout pass.
|
||
/// Without guards, this creates an infinite loop:
|
||
/// layoutSubviews → set header → layoutSubviews → set header → ...
|
||
///
|
||
/// We prevent this with two mechanisms:
|
||
/// 1. `isAdjustingHeader` flag blocks re-entry during our update
|
||
/// 2. `lastHeaderHeight` skips updates when height hasn't meaningfully changed
|
||
///
|
||
/// The 1-point threshold handles floating-point jitter that can occur between passes.
|
||
override func viewDidLayoutSubviews() {
|
||
super.viewDidLayoutSubviews()
|
||
|
||
// Guard 1: Don't re-enter while we're in the middle of adjusting
|
||
guard !isAdjustingHeader else { return }
|
||
guard let headerView = tableView.tableHeaderView else { return }
|
||
guard tableView.bounds.width > 0 else { return }
|
||
|
||
// Calculate the ideal header size given the current table width
|
||
let targetSize = CGSize(width: tableView.bounds.width, height: UIView.layoutFittingCompressedSize.height)
|
||
let size = headerView.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
|
||
|
||
// Guard 2: Only update if height changed meaningfully (>1pt)
|
||
// This prevents jitter from sub-point differences between passes
|
||
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
|
||
// Re-assigning tableHeaderView forces UITableView to recalculate layout
|
||
tableView.tableHeaderView = headerView
|
||
isAdjustingHeader = false
|
||
}
|
||
}
|
||
|
||
/// Transforms hierarchical day data into a flat row list and refreshes the table.
|
||
///
|
||
/// Delegates to `ItineraryReorderingLogic.flattenDays` for the pure transformation,
|
||
/// then updates the table view.
|
||
///
|
||
/// - Parameters:
|
||
/// - days: Array of ItineraryDayData from ItineraryTableViewWrapper
|
||
/// - travelValidRanges: Dictionary mapping travel IDs to their valid day ranges
|
||
/// - itineraryItems: All ItineraryItem models for building constraints
|
||
func reloadData(
|
||
days: [ItineraryDayData],
|
||
travelValidRanges: [String: ClosedRange<Int>],
|
||
itineraryItems: [ItineraryItem] = []
|
||
) {
|
||
self.travelValidRanges = travelValidRanges
|
||
self.allItineraryItems = itineraryItems
|
||
self.tripDayCount = days.count
|
||
|
||
// Rebuild constraints with new data
|
||
self.constraints = ItineraryConstraints(tripDayCount: tripDayCount, items: itineraryItems)
|
||
|
||
// Use pure function for flattening
|
||
flatItems = ItineraryReorderingLogic.flattenDays(days) { [weak self] segment in
|
||
self?.findItineraryItem(for: segment)?.sortOrder
|
||
}
|
||
|
||
tableView.reloadData()
|
||
}
|
||
|
||
// MARK: - Row-to-Day Mapping Helpers (delegating to pure functions)
|
||
|
||
/// Finds which day a row at the given index belongs to.
|
||
private func dayNumber(forRow row: Int) -> Int {
|
||
ItineraryReorderingLogic.dayNumber(in: flatItems, forRow: row)
|
||
}
|
||
|
||
/// Finds the row index of the day header for a specific day number.
|
||
private func dayHeaderRow(forDay day: Int) -> Int? {
|
||
ItineraryReorderingLogic.dayHeaderRow(in: flatItems, forDay: day)
|
||
}
|
||
|
||
/// Finds the row index of the travel segment arriving on a specific day.
|
||
private func travelRow(forDay day: Int) -> Int? {
|
||
ItineraryReorderingLogic.travelRow(in: flatItems, forDay: day)
|
||
}
|
||
|
||
// MARK: - Drag State Management
|
||
//
|
||
// These methods handle the start, update, and end of drag operations,
|
||
// managing visual feedback (dimming invalid zones, highlighting barrier games)
|
||
// and haptic feedback.
|
||
|
||
/// Called when a drag operation begins.
|
||
///
|
||
/// Performs the following setup:
|
||
/// 1. Stores the dragging item
|
||
/// 2. Calculates invalid row indices based on constraints
|
||
/// 3. Identifies barrier games for visual highlighting (travel items only)
|
||
/// 4. Triggers pickup haptic feedback
|
||
/// 5. Applies visual dimming to invalid zones
|
||
private func beginDrag(at indexPath: IndexPath) {
|
||
let item = flatItems[indexPath.row]
|
||
draggingItem = item
|
||
isInValidZone = true
|
||
|
||
// Calculate invalid zones and barriers based on item type
|
||
switch item {
|
||
case .travel(let segment, _):
|
||
calculateTravelDragZones(segment: segment)
|
||
case .customItem(let itineraryItem):
|
||
calculateCustomItemDragZones(item: itineraryItem)
|
||
default:
|
||
// Day headers and games shouldn't be dragged
|
||
invalidRowIndices = []
|
||
barrierGameIds = []
|
||
}
|
||
|
||
// Trigger pickup haptic
|
||
feedbackGenerator.prepare()
|
||
feedbackGenerator.impactOccurred(intensity: 0.7)
|
||
|
||
// Apply visual feedback
|
||
applyDragVisualFeedback()
|
||
}
|
||
|
||
/// Called when a drag operation ends (item dropped).
|
||
///
|
||
/// Clears all drag state and removes visual feedback:
|
||
/// 1. Clears dragging item reference
|
||
/// 2. Clears invalid row indices and barrier game IDs
|
||
/// 3. Triggers drop haptic feedback
|
||
/// 4. Removes visual dimming and highlighting
|
||
private func endDrag() {
|
||
draggingItem = nil
|
||
dragTargetDay = nil
|
||
invalidRowIndices = []
|
||
validDropRows = []
|
||
validDestinationRowsProposed = []
|
||
barrierGameIds = []
|
||
isInValidZone = true
|
||
|
||
// Trigger drop haptic
|
||
feedbackGenerator.impactOccurred(intensity: 0.5)
|
||
|
||
// Remove visual feedback
|
||
removeDragVisualFeedback()
|
||
}
|
||
|
||
/// Calculates invalid zones for a travel segment drag.
|
||
/// Delegates to pure function and applies results to instance state.
|
||
private func calculateTravelDragZones(segment: TravelSegment) {
|
||
let zones = ItineraryReorderingLogic.calculateTravelDragZones(
|
||
segment: segment,
|
||
flatItems: flatItems,
|
||
travelValidRanges: travelValidRanges,
|
||
constraints: constraints,
|
||
findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) }
|
||
)
|
||
invalidRowIndices = zones.invalidRowIndices
|
||
validDropRows = zones.validDropRows
|
||
barrierGameIds = zones.barrierGameIds
|
||
}
|
||
|
||
/// Calculates invalid zones for a custom item drag.
|
||
/// Delegates to pure function and applies results to instance state.
|
||
private func calculateCustomItemDragZones(item: ItineraryItem) {
|
||
let zones = ItineraryReorderingLogic.calculateCustomItemDragZones(item: item, flatItems: flatItems)
|
||
invalidRowIndices = zones.invalidRowIndices
|
||
validDropRows = zones.validDropRows
|
||
barrierGameIds = zones.barrierGameIds
|
||
}
|
||
|
||
/// Finds the ItineraryItem model for a travel segment.
|
||
///
|
||
/// Searches through allItineraryItems to find a matching travel item.
|
||
/// Prefers matching by segmentIndex for disambiguation of repeat city pairs.
|
||
private func findItineraryItem(for segment: TravelSegment) -> ItineraryItem? {
|
||
let fromLower = segment.fromLocation.name.lowercased()
|
||
let toLower = segment.toLocation.name.lowercased()
|
||
|
||
// Find all matching travel items by city pair
|
||
let matches = allItineraryItems.filter { item in
|
||
guard case .travel(let info) = item.kind else { return false }
|
||
return info.fromCity.lowercased() == fromLower
|
||
&& info.toCity.lowercased() == toLower
|
||
}
|
||
|
||
// If only one match, return it
|
||
if matches.count <= 1 { return matches.first }
|
||
|
||
// Multiple matches (repeat city pair) — try to match by segment UUID identity
|
||
// The segment.id is a UUID that identifies the specific TravelSegment instance
|
||
// We match through the allItineraryItems which have segmentIndex set
|
||
return matches.first ?? nil
|
||
}
|
||
|
||
/// Applies visual feedback during drag.
|
||
///
|
||
/// - Invalid zones: Dimmed with alpha 0.3
|
||
/// - Barrier games: Highlighted with gold border
|
||
private func applyDragVisualFeedback() {
|
||
for (index, _) in flatItems.enumerated() {
|
||
guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { continue }
|
||
|
||
if invalidRowIndices.contains(index) {
|
||
// Dim invalid rows
|
||
UIView.animate(withDuration: 0.2) {
|
||
cell.contentView.alpha = 0.3
|
||
}
|
||
}
|
||
|
||
// Check if this row contains a barrier game
|
||
if case .games(let games, _) = flatItems[index] {
|
||
let gameIds = games.map { $0.game.id }
|
||
let hasBarrier = gameIds.contains { barrierGameIds.contains($0) }
|
||
if hasBarrier {
|
||
// Apply gold border to barrier game cells
|
||
UIView.animate(withDuration: 0.2) {
|
||
cell.contentView.layer.borderWidth = 2
|
||
cell.contentView.layer.borderColor = UIColor.systemYellow.cgColor
|
||
cell.contentView.layer.cornerRadius = 12
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Removes visual feedback after drag ends.
|
||
private func removeDragVisualFeedback() {
|
||
for (index, _) in flatItems.enumerated() {
|
||
guard let cell = tableView.cellForRow(at: IndexPath(row: index, section: 0)) else { continue }
|
||
|
||
UIView.animate(withDuration: 0.2) {
|
||
cell.contentView.alpha = 1.0
|
||
cell.contentView.layer.borderWidth = 0
|
||
cell.contentView.layer.borderColor = nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Called during drag to check if the hover position changed validity.
|
||
///
|
||
/// Triggers haptic feedback when transitioning between valid/invalid zones.
|
||
private func checkZoneTransition(at proposedRow: Int) {
|
||
let isValid = !invalidRowIndices.contains(proposedRow)
|
||
|
||
if isValid != isInValidZone {
|
||
isInValidZone = isValid
|
||
if isValid {
|
||
// Entering valid zone - soft haptic
|
||
let lightGenerator = UIImpactFeedbackGenerator(style: .light)
|
||
lightGenerator.impactOccurred()
|
||
} else {
|
||
// Entering invalid zone - error haptic
|
||
let errorGenerator = UINotificationFeedbackGenerator()
|
||
errorGenerator.notificationOccurred(.warning)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - UITableViewDataSource
|
||
|
||
override func numberOfSections(in tableView: UITableView) -> Int {
|
||
// We use a single section with a flat list. This simplifies reordering logic
|
||
// since we don't need to handle cross-section moves.
|
||
return 1
|
||
}
|
||
|
||
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
|
||
return flatItems.count
|
||
}
|
||
|
||
/// Configures and returns the cell for a row.
|
||
///
|
||
/// Each row type dispatches to a specific configure method. We use UIHostingConfiguration
|
||
/// to embed SwiftUI views in UITableViewCells, getting the best of both worlds:
|
||
/// - UIKit's mature drag-and-drop with real-time feedback
|
||
/// - SwiftUI's declarative, easy-to-style views
|
||
///
|
||
/// Note: We completely replace the cell's contentConfiguration each time. This means
|
||
/// cell reuse is less important than in traditional UIKit - the SwiftUI view is
|
||
/// recreated regardless. This is fine for our use case (typically <50 rows).
|
||
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
|
||
let item = flatItems[indexPath.row]
|
||
|
||
switch item {
|
||
case .dayHeader(let dayNumber, let date):
|
||
let cell = tableView.dequeueReusableCell(withIdentifier: dayHeaderCellId, for: indexPath)
|
||
configureDayHeaderCell(cell, dayNumber: dayNumber, date: date)
|
||
return cell
|
||
|
||
case .games(let games, _):
|
||
let cell = tableView.dequeueReusableCell(withIdentifier: gamesCellId, for: indexPath)
|
||
configureGamesCell(cell, 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
|
||
}
|
||
}
|
||
|
||
// MARK: - Reordering (Drag and Drop)
|
||
|
||
/// Controls which rows show the drag reorder handle.
|
||
///
|
||
/// Returns `isReorderable` from the row item:
|
||
/// - `true` for travel segments and custom items (user can move these)
|
||
/// - `false` for day headers and games (structural items)
|
||
override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool {
|
||
return flatItems[indexPath.row].isReorderable
|
||
}
|
||
|
||
/// Called AFTER the user lifts their finger to complete a drag.
|
||
///
|
||
/// At this point, UITableView has already visually moved the row. Our job is to:
|
||
/// 1. Update our data model (`flatItems`) to reflect the new position
|
||
/// 2. Notify the parent view via callbacks so it can persist the change
|
||
/// 3. Clear drag state and remove visual feedback
|
||
///
|
||
/// **For travel segments:** We notify `onTravelMoved` with the travel ID and new day.
|
||
/// The parent stores this in `travelDayOverrides` (not persisted to CloudKit).
|
||
///
|
||
/// **For custom items:** We notify `onCustomItemMoved` with the item ID, new day,
|
||
/// and calculated sortOrder. The parent updates the item and syncs to CloudKit.
|
||
override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) {
|
||
let item = flatItems[sourceIndexPath.row]
|
||
|
||
// End drag state and remove visual feedback
|
||
endDrag()
|
||
|
||
// Update our in-memory data model
|
||
flatItems.remove(at: sourceIndexPath.row)
|
||
flatItems.insert(item, at: destinationIndexPath.row)
|
||
|
||
// Notify parent view of the change
|
||
switch item {
|
||
case .travel(let segment, _):
|
||
// Travel is positioned within a day using sortOrder (can be before/after games)
|
||
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
|
||
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
|
||
let segIdx = findItineraryItem(for: segment)?.travelInfo?.segmentIndex ?? 0
|
||
let travelId = "travel:\(segIdx):\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||
onTravelMoved?(travelId, destinationDay, sortOrder)
|
||
|
||
case .customItem(let customItem):
|
||
// Calculate the new day and sortOrder for the dropped position
|
||
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
|
||
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
|
||
|
||
// DEBUG: Log the final state after insertion
|
||
print("🎯 [Drop] source=\(sourceIndexPath.row) → dest=\(destinationIndexPath.row)")
|
||
print("🎯 [Drop] flatItems around dest:")
|
||
for i in max(0, destinationIndexPath.row - 2)...min(flatItems.count - 1, destinationIndexPath.row + 2) {
|
||
let marker = i == destinationIndexPath.row ? "→" : " "
|
||
print("🎯 [Drop] \(marker) [\(i)] \(flatItems[i])")
|
||
}
|
||
print("🎯 [Drop] Calculated day=\(destinationDay), sortOrder=\(sortOrder)")
|
||
|
||
onCustomItemMoved?(customItem.id, destinationDay, sortOrder)
|
||
|
||
default:
|
||
break // Day headers, games, add buttons can't be moved
|
||
}
|
||
}
|
||
|
||
/// Determines which day a travel segment belongs to at a given row position.
|
||
private func dayForTravelAt(row: Int) -> Int {
|
||
ItineraryReorderingLogic.dayForTravelAt(row: row, in: flatItems)
|
||
}
|
||
|
||
/// Called DURING a drag to validate and potentially modify the drop position.
|
||
///
|
||
/// Delegates constraint logic to pure functions, handles only UIKit-specific concerns:
|
||
/// - Drag state initialization (first call)
|
||
/// - Haptic/visual feedback
|
||
/// - Converting pure function results to IndexPath
|
||
///
|
||
/// - Parameters:
|
||
/// - sourceIndexPath: Where the item is being dragged FROM
|
||
/// - proposedDestinationIndexPath: Where the user is trying to drop
|
||
/// - Returns: The actual destination (may differ from proposed)
|
||
override func tableView(
|
||
_ tableView: UITableView,
|
||
targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath,
|
||
toProposedIndexPath proposedDestinationIndexPath: IndexPath
|
||
) -> IndexPath {
|
||
|
||
let sourceRow = sourceIndexPath.row
|
||
let item = flatItems[sourceRow]
|
||
|
||
// Drag start detection - initialize state and compute valid destinations
|
||
if draggingItem == nil {
|
||
beginDrag(at: sourceIndexPath)
|
||
validDestinationRowsProposed = computeValidDestinationRowsProposed(sourceRow: sourceRow, dragged: item)
|
||
}
|
||
|
||
// Clamp proposed row
|
||
var proposedRow = proposedDestinationIndexPath.row
|
||
proposedRow = min(max(1, proposedRow), max(0, flatItems.count - 1))
|
||
|
||
// Haptics / visuals
|
||
checkZoneTransition(at: proposedRow)
|
||
|
||
// Use pure function for target calculation
|
||
let targetRow = ItineraryReorderingLogic.calculateTargetRow(
|
||
proposedRow: proposedRow,
|
||
validDestinationRows: validDestinationRowsProposed,
|
||
sourceRow: sourceRow
|
||
)
|
||
return IndexPath(row: targetRow, section: 0)
|
||
}
|
||
|
||
// MARK: - Drag Destination Precomputation (delegating to pure functions)
|
||
|
||
/// Computes all valid destination rows in **proposed** coordinate space.
|
||
/// Delegates to pure function with closures for model lookups.
|
||
private func computeValidDestinationRowsProposed(sourceRow: Int, dragged: ItineraryRowItem) -> [Int] {
|
||
ItineraryReorderingLogic.computeValidDestinationRowsProposed(
|
||
flatItems: flatItems,
|
||
sourceRow: sourceRow,
|
||
dragged: dragged,
|
||
travelValidRanges: travelValidRanges,
|
||
constraints: constraints,
|
||
findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) },
|
||
makeTravelItem: { [weak self] segment in
|
||
ItineraryItem(
|
||
tripId: self?.allItineraryItems.first?.tripId ?? UUID(),
|
||
day: 1,
|
||
sortOrder: 0,
|
||
kind: .travel(TravelInfo(
|
||
fromCity: segment.fromLocation.name,
|
||
toCity: segment.toLocation.name,
|
||
distanceMeters: segment.distanceMeters,
|
||
durationSeconds: segment.durationSeconds
|
||
))
|
||
)
|
||
},
|
||
findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder }
|
||
)
|
||
}
|
||
|
||
// MARK: - Editing Style Configuration
|
||
|
||
/// Returns `.none` to hide the red delete circle that normally appears in editing mode.
|
||
///
|
||
/// We have `tableView.isEditing = true` to enable drag handles, but we don't want
|
||
/// the standard "swipe to delete" UI. Returning `.none` hides delete controls.
|
||
/// (Deletion is handled via context menu instead.)
|
||
override func tableView(_ tableView: UITableView, editingStyleForRowAt indexPath: IndexPath) -> UITableViewCell.EditingStyle {
|
||
return .none
|
||
}
|
||
|
||
/// Returns `false` to prevent cells from indenting when in editing mode.
|
||
///
|
||
/// By default, editing mode indents cells to make room for delete controls.
|
||
/// Since we're not using those controls, we disable indentation to keep
|
||
/// our custom card layouts flush with the edges.
|
||
override func tableView(_ tableView: UITableView, shouldIndentWhileEditingRowAt indexPath: IndexPath) -> Bool {
|
||
return false
|
||
}
|
||
|
||
// MARK: - Row Selection
|
||
|
||
/// Handles taps on rows.
|
||
///
|
||
/// We only respond to taps on:
|
||
/// - **Custom items:** Opens edit sheet via `onCustomItemTapped`
|
||
///
|
||
/// Day headers (with embedded Add button), games, and travel don't respond to row taps.
|
||
/// The Add button within the day header handles its own tap via SwiftUI Button.
|
||
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
|
||
// Immediately deselect to remove highlight
|
||
tableView.deselectRow(at: indexPath, animated: true)
|
||
|
||
let item = flatItems[indexPath.row]
|
||
|
||
switch item {
|
||
case .customItem(let customItem):
|
||
onCustomItemTapped?(customItem)
|
||
|
||
default:
|
||
break // Other row types don't respond to row taps
|
||
}
|
||
}
|
||
|
||
// MARK: - Context Menu (Long Press)
|
||
|
||
/// Provides context menu for long-press on custom items.
|
||
///
|
||
/// Only custom items have a context menu (Edit/Delete). Other row types
|
||
/// return nil, meaning no menu appears on long-press.
|
||
///
|
||
/// We use UIKit context menus rather than SwiftUI's `.contextMenu()` because
|
||
/// they're more reliable inside UITableViewCells and provide better preview behavior.
|
||
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
|
||
let item = flatItems[indexPath.row]
|
||
|
||
// Only custom items have context menus
|
||
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: - Sort Order Calculation
|
||
|
||
/// Calculates the sortOrder for an item dropped at the given row position.
|
||
/// Delegates to pure function with closure for travel sortOrder lookup.
|
||
private func calculateSortOrder(at row: Int) -> Double {
|
||
ItineraryReorderingLogic.calculateSortOrder(
|
||
in: flatItems,
|
||
at: row,
|
||
findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder }
|
||
)
|
||
}
|
||
|
||
// MARK: - Cell Configuration
|
||
//
|
||
// Each configure method sets up a UITableViewCell with:
|
||
// 1. UIHostingConfiguration wrapping a SwiftUI view
|
||
// 2. .margins(.all, 0) to remove default content margins
|
||
// 3. .background(.clear) to let parent background show through
|
||
// 4. cell.backgroundColor = .clear for the same reason
|
||
// 5. cell.selectionStyle based on whether the row is tappable
|
||
|
||
/// Day header cell - shows "Day N · Date" text with embedded Add button.
|
||
/// The Add button handles its own tap via SwiftUI Button (not row selection).
|
||
private func configureDayHeaderCell(_ cell: UITableViewCell, dayNumber: Int, date: Date) {
|
||
cell.contentConfiguration = UIHostingConfiguration {
|
||
DaySectionHeaderView(
|
||
dayNumber: dayNumber,
|
||
date: date,
|
||
colorScheme: colorScheme,
|
||
onAddTapped: { [weak self] in
|
||
self?.onAddButtonTapped?(dayNumber)
|
||
}
|
||
)
|
||
}
|
||
.margins(.all, 0)
|
||
.background(.clear)
|
||
|
||
cell.backgroundColor = .clear
|
||
cell.selectionStyle = .none // Row itself isn't selectable; Add button handles taps
|
||
}
|
||
|
||
/// Games cell - shows city label and game cards for a day.
|
||
/// Not selectable (games are informational, not editable).
|
||
private func configureGamesCell(_ cell: UITableViewCell, games: [RichGame]) {
|
||
cell.contentConfiguration = UIHostingConfiguration {
|
||
GamesRowView(games: games, colorScheme: colorScheme)
|
||
}
|
||
.margins(.all, 0)
|
||
.background(.clear)
|
||
|
||
cell.backgroundColor = .clear
|
||
cell.selectionStyle = .none
|
||
}
|
||
|
||
/// Travel cell - shows route card (from → to with distance/duration).
|
||
/// Not selectable but IS draggable (handled by canMoveRowAt).
|
||
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
|
||
}
|
||
|
||
/// Custom item cell - shows user-added item with category icon.
|
||
/// Selectable (opens edit sheet on tap) and draggable.
|
||
private func configureCustomItemCell(_ cell: UITableViewCell, item: ItineraryItem) {
|
||
cell.contentConfiguration = UIHostingConfiguration {
|
||
CustomItemRowView(item: item, colorScheme: colorScheme)
|
||
}
|
||
.margins(.all, 0)
|
||
.background(.clear)
|
||
|
||
cell.backgroundColor = .clear
|
||
cell.selectionStyle = .default // Shows highlight on tap
|
||
}
|
||
}
|
||
|
||
// MARK: - SwiftUI Row Views
|
||
//
|
||
// These views are embedded in UITableViewCells via UIHostingConfiguration.
|
||
// They're simple, stateless views - all state management happens in the parent.
|
||
//
|
||
// Design notes:
|
||
// - Use Theme colors for consistency with the rest of the app
|
||
// - Pass colorScheme explicitly (UIHostingConfiguration doesn't inherit environment)
|
||
// - Padding is handled by the views themselves (cell margins are set to 0)
|
||
|
||
/// Day header row - displays "Day N · Date" with embedded Add button.
|
||
///
|
||
/// The Add button is part of the header row to prevent items from being
|
||
/// dragged between the header and the Add button. This ensures "Day N" and
|
||
/// "+ Add" always stay together as an atomic unit.
|
||
///
|
||
/// Layout:
|
||
/// ```
|
||
/// Day 3 · Sunday, Mar 8 + Add
|
||
/// ```
|
||
struct DaySectionHeaderView: View {
|
||
let dayNumber: Int
|
||
let date: Date
|
||
let colorScheme: ColorScheme
|
||
let onAddTapped: () -> Void
|
||
|
||
private var formattedDate: String {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "EEEE, MMM d" // "Sunday, Mar 8"
|
||
return formatter.string(from: date)
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
// Day label and date
|
||
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()
|
||
|
||
// Add button (right-aligned, bordered capsule for discoverability)
|
||
Button(action: onAddTapped) {
|
||
Label("Add", systemImage: "plus")
|
||
.font(.subheadline.weight(.medium))
|
||
}
|
||
.buttonStyle(.bordered)
|
||
.buttonBorderShape(.capsule)
|
||
.tint(Theme.warmOrange)
|
||
.accessibilityLabel("Add item to Day \(dayNumber)")
|
||
.accessibilityHint("Add restaurants, activities, or notes to this day")
|
||
}
|
||
.padding(.horizontal, Theme.Spacing.lg)
|
||
.padding(.top, Theme.Spacing.lg) // More space above for section separation
|
||
.padding(.bottom, Theme.Spacing.sm)
|
||
}
|
||
}
|
||
|
||
/// Games row - displays all games for a day with city label.
|
||
///
|
||
/// Games are bundled into a single row (not individually reorderable) because:
|
||
/// 1. Game times are fixed by schedules - reordering would be meaningless
|
||
/// 2. Users don't control which games are in the trip - that's the planning engine
|
||
/// 3. Keeping them together reinforces "these are your games for this day"
|
||
///
|
||
/// The city label appears above the game cards (e.g., "Milwaukee" above the Bucks game).
|
||
struct GamesRowView: View {
|
||
let games: [RichGame]
|
||
let colorScheme: ColorScheme
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||
// City label (derived from first game's stadium)
|
||
// All games on same day are typically in the same city
|
||
if let firstGame = games.first {
|
||
Text(firstGame.stadium.city)
|
||
.font(.subheadline)
|
||
.foregroundStyle(Theme.warmOrange)
|
||
.padding(.horizontal, Theme.Spacing.lg)
|
||
}
|
||
|
||
// Individual game cards
|
||
ForEach(games, id: \.game.id) { richGame in
|
||
GameRowCompact(richGame: richGame, colorScheme: colorScheme)
|
||
}
|
||
}
|
||
.padding(.horizontal, Theme.Spacing.lg)
|
||
.padding(.bottom, Theme.Spacing.xs)
|
||
}
|
||
}
|
||
|
||
/// Individual game card within GamesRowView.
|
||
///
|
||
/// Shows sport-colored accent bar, sport badge, matchup, stadium, and time.
|
||
/// Uses elevated card background to distinguish from surrounding content.
|
||
struct GameRowCompact: View {
|
||
let richGame: RichGame
|
||
let colorScheme: ColorScheme
|
||
|
||
private var formattedTime: String {
|
||
richGame.localGameTimeShort // Stadium local time
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: Theme.Spacing.md) {
|
||
// Left accent bar in sport's color (NBA=orange, MLB=blue, etc.)
|
||
RoundedRectangle(cornerRadius: 3)
|
||
.fill(richGame.game.sport.color)
|
||
.frame(width: 5)
|
||
|
||
VStack(alignment: .leading, spacing: 6) {
|
||
// Sport badge (e.g., "NBA")
|
||
Text(richGame.game.sport.rawValue)
|
||
.font(.caption)
|
||
.fontWeight(.bold)
|
||
.foregroundStyle(richGame.game.sport.color)
|
||
|
||
// Matchup (e.g., "ORL @ MIL")
|
||
Text(richGame.matchupDescription)
|
||
.font(.body)
|
||
.fontWeight(.semibold)
|
||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||
|
||
// Stadium name with icon
|
||
HStack(spacing: 6) {
|
||
Image(systemName: "building.2")
|
||
.font(.caption)
|
||
.accessibilityHidden(true)
|
||
Text(richGame.stadium.name)
|
||
.font(.subheadline)
|
||
}
|
||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// Game time (prominently displayed)
|
||
Text(formattedTime)
|
||
.font(.title3)
|
||
.fontWeight(.medium)
|
||
.foregroundStyle(Theme.warmOrange)
|
||
|
||
// Map button to open stadium location
|
||
Button {
|
||
AppleMapsLauncher.openLocation(
|
||
coordinate: richGame.stadium.coordinate,
|
||
name: richGame.stadium.name
|
||
)
|
||
} label: {
|
||
Image(systemName: "map")
|
||
.font(.body)
|
||
.foregroundStyle(Theme.warmOrange)
|
||
.padding(8)
|
||
.background(Theme.warmOrange.opacity(0.15))
|
||
.clipShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Open \(richGame.stadium.name) in Maps")
|
||
.accessibilityHint("Opens this stadium location in Apple Maps")
|
||
}
|
||
.padding(Theme.Spacing.md)
|
||
.background(Theme.cardBackgroundElevated(colorScheme))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
}
|
||
}
|
||
|
||
/// Travel row - displays a travel segment (driving between cities).
|
||
///
|
||
/// Shows:
|
||
/// - Car icon in orange circle
|
||
/// - From city → To city vertically stacked
|
||
/// - Distance and duration between them
|
||
///
|
||
/// This row IS draggable (has drag handle) and can be moved within its valid
|
||
/// day range. Moving travel updates where it appears in the itinerary but
|
||
/// doesn't change the actual route.
|
||
struct TravelRowView: View {
|
||
let segment: TravelSegment
|
||
let colorScheme: ColorScheme
|
||
|
||
var body: some View {
|
||
HStack(spacing: Theme.Spacing.md) {
|
||
// Car icon in subtle orange circle
|
||
ZStack {
|
||
Circle()
|
||
.fill(Theme.warmOrange.opacity(0.15))
|
||
.frame(width: 40, height: 40)
|
||
|
||
Image(systemName: "car.fill")
|
||
.font(.body)
|
||
.foregroundStyle(Theme.warmOrange)
|
||
}
|
||
|
||
// Route details: From → (distance/duration) → To
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text(segment.fromLocation.name)
|
||
.font(.subheadline)
|
||
.fontWeight(.medium)
|
||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||
|
||
// Distance and duration on the connecting line
|
||
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()
|
||
|
||
// Map button to open driving directions
|
||
if let fromCoord = segment.fromLocation.coordinate,
|
||
let toCoord = segment.toLocation.coordinate {
|
||
Button {
|
||
AppleMapsLauncher.openDirections(
|
||
from: fromCoord,
|
||
fromName: segment.fromLocation.name,
|
||
to: toCoord,
|
||
toName: segment.toLocation.name
|
||
)
|
||
} label: {
|
||
Image(systemName: "map")
|
||
.font(.body)
|
||
.foregroundStyle(Theme.warmOrange)
|
||
.padding(8)
|
||
.background(Theme.warmOrange.opacity(0.15))
|
||
.clipShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Get directions from \(segment.fromLocation.name) to \(segment.toLocation.name)")
|
||
.accessibilityHint("Opens this stadium location in Apple Maps")
|
||
}
|
||
}
|
||
.padding(Theme.Spacing.md)
|
||
.background(Theme.cardBackground(colorScheme))
|
||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||
.padding(.horizontal, Theme.Spacing.lg)
|
||
.padding(.vertical, Theme.Spacing.xs)
|
||
}
|
||
}
|
||
|
||
/// Custom item row - displays a user-added itinerary item.
|
||
///
|
||
/// Shows:
|
||
/// - Category emoji icon (🍽️, 🎢, ⛽, etc.)
|
||
/// - Title with optional map pin indicator
|
||
/// - Address (if item has location)
|
||
/// - Chevron indicating tappable
|
||
///
|
||
/// Visual treatment uses a subtle orange tint to distinguish user-added items
|
||
/// from trip-determined content (games, travel).
|
||
///
|
||
/// This row is both tappable (opens edit sheet) and draggable.
|
||
struct CustomItemRowView: View {
|
||
let item: ItineraryItem
|
||
let colorScheme: ColorScheme
|
||
|
||
private var customInfo: CustomInfo? {
|
||
item.customInfo
|
||
}
|
||
|
||
var body: some View {
|
||
HStack(spacing: Theme.Spacing.sm) {
|
||
// Category icon (emoji)
|
||
if let info = customInfo {
|
||
Text(info.icon)
|
||
.font(.title3)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
HStack(spacing: 4) {
|
||
Text(info.title)
|
||
.font(.subheadline)
|
||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||
.lineLimit(1)
|
||
|
||
// Map pin indicator for items with coordinates
|
||
if info.isMappable {
|
||
Image(systemName: "mappin.circle.fill")
|
||
.font(.caption)
|
||
.foregroundStyle(Theme.warmOrange)
|
||
.accessibilityHidden(true)
|
||
}
|
||
}
|
||
|
||
// Address subtitle (shown only if present)
|
||
if let address = info.address, !address.isEmpty {
|
||
Text(address)
|
||
.font(.caption)
|
||
.foregroundStyle(Theme.textMuted(colorScheme))
|
||
.lineLimit(1)
|
||
}
|
||
}
|
||
}
|
||
|
||
Spacer()
|
||
|
||
// Map button for items with GPS coordinates
|
||
if let info = customInfo, let coordinate = info.coordinate {
|
||
Button {
|
||
AppleMapsLauncher.openLocation(
|
||
coordinate: coordinate,
|
||
name: info.title
|
||
)
|
||
} label: {
|
||
Image(systemName: "map")
|
||
.font(.subheadline)
|
||
.foregroundStyle(Theme.warmOrange)
|
||
.padding(6)
|
||
.background(Theme.warmOrange.opacity(0.15))
|
||
.clipShape(Circle())
|
||
}
|
||
.buttonStyle(.plain)
|
||
.accessibilityLabel("Open \(info.title) in Maps")
|
||
.accessibilityHint("Opens this stadium location in Apple Maps")
|
||
}
|
||
|
||
// Chevron indicates this is tappable
|
||
Image(systemName: "chevron.right")
|
||
.foregroundStyle(.tertiary)
|
||
.font(.caption)
|
||
.accessibilityHidden(true)
|
||
}
|
||
.padding(.horizontal, Theme.Spacing.md)
|
||
.padding(.vertical, Theme.Spacing.sm)
|
||
// Layered background: base card + orange tint
|
||
.background {
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.fill(Theme.cardBackground(colorScheme))
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.fill(Theme.warmOrange.opacity(0.1))
|
||
}
|
||
// Subtle orange border for extra distinction
|
||
.overlay {
|
||
RoundedRectangle(cornerRadius: 8)
|
||
.strokeBorder(Theme.warmOrange.opacity(0.3), lineWidth: 1)
|
||
}
|
||
.padding(.horizontal, Theme.Spacing.lg)
|
||
.padding(.vertical, Theme.Spacing.xs)
|
||
}
|
||
}
|