Files
Sportstime/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
Trey t 143b364553 wip
2026-01-18 12:32:58 -06:00

1781 lines
78 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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:detroit->milwaukee" (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 are lowercase to ensure consistency across sessions.
var id: String {
switch self {
case .dayHeader(let dayNumber, _):
return "day:\(dayNumber)"
case .games(_, let dayNumber):
return "games:\(dayNumber)"
case .travel(let segment, _):
// Lowercase ensures stable ID regardless of display capitalization
return "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
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.
///
/// This is the core data transformation method. It takes structured `[ItineraryDayData]`
/// from the wrapper and flattens it into `[ItineraryRowItem]` for UITableView display.
///
/// **Flattening Algorithm:**
/// For each day, rows are added in this exact order:
/// 1. Travel (if arriving this day) - appears visually BEFORE the day header
/// 2. Day header (with Add button) - "Day N · Date" + tappable Add button
/// 3. Games - all games for this day (grouped as one row)
/// 4. Custom items - user-added items, already sorted by sortOrder
///
/// **Why this order matters:**
/// - Travel before header creates visual grouping: "you travel, then you're on day N"
/// - Add button is part of header row (can't drag items between header and Add)
/// - Games before custom items preserves the "trip-determined, then user-added" hierarchy
///
/// - 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)
flatItems = []
for day in days {
// 1. Travel that arrives on this day (renders BEFORE the day header)
// Example: "Detroit Milwaukee" appears above "Day 3" header
if let travel = day.travelBefore {
flatItems.append(.travel(travel, dayNumber: day.dayNumber))
}
// 2. Day header with Add button (structural anchor - cannot be moved or deleted)
// Add button is embedded in the header to prevent items being dragged between them
flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
// 3. Movable items (travel + custom) split around games boundary.
// Convention: sortOrder < 0 renders ABOVE games; sortOrder >= 0 renders BELOW games.
var beforeGames: [ItineraryRowItem] = []
var afterGames: [ItineraryRowItem] = []
for row in day.items {
let so: Double?
switch row {
case .customItem(let item):
so = item.sortOrder
case .travel(let segment, _):
// Travel sortOrder is stored in itineraryItems (kind: .travel)
so = findItineraryItem(for: segment)?.sortOrder
default:
so = nil
}
guard let sortOrder = so else { continue }
if sortOrder < 0 {
beforeGames.append(row)
} else {
afterGames.append(row)
}
}
flatItems.append(contentsOf: beforeGames)
// 4. Games for this day (bundled as one row, not individually reorderable)
if !day.games.isEmpty {
flatItems.append(.games(day.games, dayNumber: day.dayNumber))
}
flatItems.append(contentsOf: afterGames)
}
tableView.reloadData()
}
// MARK: - Row-to-Day Mapping Helpers
/// Finds which day a row at the given index belongs to.
///
/// Scans backwards from the row to find either:
/// - A `.dayHeader` that's the day
/// - A `.travel` uses the dayNumber stored in the travel item
///
/// This is used when a custom item is dropped to determine its new day.
private func dayNumber(forRow row: Int) -> Int {
for i in stride(from: row, through: 0, by: -1) {
if case .dayHeader(let dayNum, _) = flatItems[i] {
return dayNum
}
}
return 1
}
/// Finds the row index of the day header for a specific day number.
/// Returns nil if no header exists for that day (shouldn't happen in valid data).
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
}
/// Finds the row index of the travel segment arriving on a specific day.
/// Returns nil if no travel arrives on that day.
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: - 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.
///
/// Travel items have hard constraints:
/// - Can't leave before finishing games in departure city
/// - Must arrive by the first game in destination city
///
/// Invalid zones are any rows outside the valid day range.
private func calculateTravelDragZones(segment: TravelSegment) {
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
// Get valid day range from pre-calculated ranges
guard let validRange = travelValidRanges[travelId] else {
invalidRowIndices = []
validDropRows = []
barrierGameIds = []
return
}
// Calculate invalid and valid row indices based on day range
// Pre-calculate ALL valid positions for stable drag behavior
var invalidRows = Set<Int>()
var validRows: [Int] = []
for (index, rowItem) in flatItems.enumerated() {
let dayNum: Int
switch rowItem {
case .dayHeader(let d, _):
dayNum = d
case .games(_, let d):
dayNum = d
case .travel(_, let d):
dayNum = d
case .customItem(let item):
dayNum = item.day
}
if validRange.contains(dayNum) {
validRows.append(index)
} else {
invalidRows.insert(index)
}
}
invalidRowIndices = invalidRows
validDropRows = validRows // Already sorted since we iterate in order
// Find barrier games using ItineraryConstraints
if let travelItem = findItineraryItem(for: segment),
let constraints = constraints {
let barriers = constraints.barrierGames(for: travelItem)
barrierGameIds = Set(barriers.compactMap { $0.gameId })
} else {
barrierGameIds = []
}
}
/// Calculates invalid zones for a custom item drag.
///
/// Custom items can go on any day, but we mark certain positions as
/// less ideal (e.g., directly on day headers or before travel).
private func calculateCustomItemDragZones(item: ItineraryItem) {
// Custom items are flexible - can go anywhere except ON day headers
// Pre-calculate ALL valid row indices for stable drag behavior
var invalidRows = Set<Int>()
var validRows: [Int] = []
for (index, rowItem) in flatItems.enumerated() {
if case .dayHeader = rowItem {
invalidRows.insert(index)
} else {
// All non-header rows are valid drop targets
validRows.append(index)
}
}
invalidRowIndices = invalidRows
validDropRows = validRows // Already sorted since we iterate in order
barrierGameIds = [] // No barrier highlighting for custom items
}
/// Finds the ItineraryItem model for a travel segment.
///
/// Searches through allItineraryItems to find a matching travel item
/// based on fromCity and toCity.
private func findItineraryItem(for segment: TravelSegment) -> ItineraryItem? {
return allItineraryItems.first { item in
guard case .travel(let info) = item.kind else { return false }
return info.fromCity.lowercased() == segment.fromLocation.name.lowercased()
&& info.toCity.lowercased() == segment.toLocation.name.lowercased()
}
}
/// 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 travelId = "travel:\(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.
///
/// Travel conceptually "arrives on" a day - it appears BEFORE that day's header.
/// So we scan FORWARD from the travel's position to find the next day header.
///
/// Example:
/// ```
/// [0] Travel: Detroit Milwaukee If travel is here...
/// [1] Day 3 header ...it belongs to Day 3
/// ```
private func dayForTravelAt(row: Int) -> Int {
// Scan forward to find the day header this travel precedes
for i in row..<flatItems.count {
if case .dayHeader(let dayNum, _) = flatItems[i] {
return dayNum
}
}
// Fallback: scan backwards to find any day header
for i in stride(from: flatItems.count - 1, through: 0, by: -1) {
if case .dayHeader(let dayNum, _) = flatItems[i] {
return dayNum
}
}
return 1 // Ultimate fallback
}
/// Called DURING a drag to validate and potentially modify the drop position.
///
/// This is the core drag constraint logic. UITableView calls this continuously
/// as the user drags, allowing us to redirect the drop to a valid position.
///
/// **Key behaviors:**
///
/// **Travel segments:** Constrained to their valid day range. If user tries to
/// drag outside the range, we snap to the nearest valid day. This prevents
/// impossible itineraries (e.g., arriving before you've left).
///
/// **Custom items:** Can go almost anywhere, but we prevent:
/// - Dropping ON a day header (redirect to after header)
/// - Dropping BEFORE travel at start of day (redirect to after header)
///
/// **Fixed items:** Day headers, games, add buttons return their source position
/// (they never actually drag since canMoveRowAt returns false).
///
/// **Drag State Management:**
/// - First call: Initializes drag state, calculates invalid zones, triggers pickup haptic
/// - Subsequent calls: Checks zone transitions for haptic feedback
///
/// - 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
if draggingItem == nil {
beginDrag(at: sourceIndexPath)
validDestinationRowsProposed = computeValidDestinationRowsProposed(sourceRow: sourceRow, dragged: item)
}
var proposedRow = proposedDestinationIndexPath.row
// Avoid absolute top (keeps UX sane)
if proposedRow <= 0 { proposedRow = 1 }
proposedRow = min(max(0, proposedRow), max(0, flatItems.count - 1))
// Haptics / visuals
checkZoneTransition(at: proposedRow)
// If already valid, allow it.
if validDestinationRowsProposed.contains(proposedRow) {
return IndexPath(row: proposedRow, section: 0)
}
// Snap to nearest valid destination (proposed coordinate space)
guard let snapped = nearestValue(in: validDestinationRowsProposed, to: proposedRow) else {
return sourceIndexPath
}
return IndexPath(row: snapped, section: 0)
}
// MARK: - Drag Destination Precomputation (semantic day + sortOrder)
/// Nearest value in a sorted Int array to the target (binary search).
private func nearestValue(in sorted: [Int], to target: Int) -> Int? {
guard !sorted.isEmpty else { return nil }
var low = 0
var high = sorted.count
while low < high {
let mid = (low + high) / 2
if sorted[mid] < target { low = mid + 1 } else { high = mid }
}
let after = (low < sorted.count) ? sorted[low] : nil
let before = (low > 0) ? sorted[low - 1] : nil
switch (before, after) {
case let (b?, a?):
return (target - b) <= (a - target) ? b : a
case let (b?, nil):
return b
case let (nil, a?):
return a
default:
return nil
}
}
/// Computes all valid destination rows in **proposed** coordinate space (UIKit's coordinate space during drag).
/// We simulate the move and validate using semantic constraints: (day, sortOrder).
private func computeValidDestinationRowsProposed(sourceRow: Int, dragged: ItineraryRowItem) -> [Int] {
// Proposed rows are in the array AFTER removing the source row.
let maxProposed = max(0, flatItems.count - 1)
guard maxProposed > 0 else { return [] }
switch dragged {
case .customItem:
// Custom items can go basically anywhere (including before headers = "between days").
// Keep row 0 blocked.
return Array(1...maxProposed)
case .travel(let segment, _):
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
let validDayRange = travelValidRanges[travelId]
// Use existing itinerary model if available (for constraints)
let model: ItineraryItem = findItineraryItem(for: segment) ?? ItineraryItem(
tripId: 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))
)
guard let constraints else {
// If no constraint engine, allow all rows (except 0)
return Array(1...maxProposed)
}
var valid: [Int] = []
valid.reserveCapacity(maxProposed)
for proposedRow in 1...maxProposed {
let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
let destRowInSim = simulated.destinationRowInNewArray
let day = dayNumber(in: simulated.items, forRow: destRowInSim)
if let r = validDayRange, !r.contains(day) {
continue
}
let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim)
if constraints.isValidPosition(for: model, day: day, sortOrder: sortOrder) {
valid.append(proposedRow)
}
}
return valid
default:
return []
}
}
private struct SimulatedMove {
let items: [ItineraryRowItem]
let destinationRowInNewArray: Int
}
/// Simulate UITableView move semantics: remove at sourceRow from ORIGINAL array, then insert at destinationProposedRow
/// in the NEW array (post-removal coordinate space).
private func simulateMove(original: [ItineraryRowItem], sourceRow: Int, destinationProposedRow: Int) -> SimulatedMove {
var items = original
let moving = items.remove(at: sourceRow)
let clampedDest = min(max(0, destinationProposedRow), items.count)
items.insert(moving, at: clampedDest)
return SimulatedMove(items: items, destinationRowInNewArray: clampedDest)
}
/// Day number lookup within an arbitrary flat array (used during simulation).
private func dayNumber(in items: [ItineraryRowItem], forRow row: Int) -> Int {
guard !items.isEmpty else { return 1 }
let clamped = min(max(0, row), items.count - 1)
for i in stride(from: clamped, through: 0, by: -1) {
if case .dayHeader(let dayNum, _) = items[i] {
return dayNum
}
}
return 1
}
/// Calculates sortOrder for insertion at a row within an arbitrary flat array.
/// Uses the same convention as the main function:
/// - sortOrder < 0 => above games
/// - sortOrder >= 0 => below games
private func calculateSortOrder(in items: [ItineraryRowItem], at row: Int) -> Double {
let day = dayNumber(in: items, forRow: row)
// Find games row for this day in the provided items
var gamesRow: Int? = nil
for i in 0..<items.count {
if case .games(_, let d) = items[i], d == day {
gamesRow = i
break
}
if case .dayHeader(let d, _) = items[i], d > day {
break
}
}
let isBeforeGames = (gamesRow != nil && row <= gamesRow!)
func movableSortOrder(_ idx: Int) -> Double? {
guard idx >= 0 && idx < items.count else { return nil }
switch items[idx] {
case .customItem(let item):
return item.sortOrder
case .travel(let segment, _):
return findItineraryItem(for: segment)?.sortOrder
default:
return nil
}
}
func scanBackward(from start: Int) -> Double? {
var i = start
while i >= 0 {
if case .dayHeader(let d, _) = items[i], d != day { break }
if case .dayHeader = items[i] { break }
if case .games(_, let d) = items[i], d == day { break }
if let v = movableSortOrder(i) {
if isBeforeGames {
if v < 0 { return v }
} else {
if v >= 0 { return v }
}
}
i -= 1
}
return nil
}
func scanForward(from start: Int) -> Double? {
var i = start
while i < items.count {
if case .dayHeader(let d, _) = items[i], d != day { break }
if case .dayHeader = items[i] { break }
if case .games(_, let d) = items[i], d == day { break }
if let v = movableSortOrder(i) {
if isBeforeGames {
if v < 0 { return v }
} else {
if v >= 0 { return v }
}
}
i += 1
}
return nil
}
if isBeforeGames {
let prev = scanBackward(from: row - 1)
let next = scanForward(from: row)
let upperBound: Double = 0.0
switch (prev, next) {
case (nil, nil):
return -1.0
case (let p?, nil):
return (p + upperBound) / 2.0
case (nil, let n?):
return n / 2.0
case (let p?, let n?):
return (p + n) / 2.0
}
} else {
let prev = scanBackward(from: row - 1) ?? 0.0
let next = scanForward(from: row)
switch next {
case nil:
return (prev == 0.0) ? 1.0 : (prev + 1.0)
case let n?:
return (prev + n) / 2.0
}
}
}
private func dayForTravelAtProposed(row: Int, excluding: Int) -> Int {
// Scan forward, skipping the item being moved
for i in row..<flatItems.count {
if i == excluding { continue }
if case .dayHeader(let dayNum, _) = flatItems[i] {
return dayNum
}
}
// Fallback: scan backwards
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 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.
///
/// Uses **midpoint insertion** algorithm to avoid renumbering existing items:
///
/// ```
/// Existing items: A (sortOrder: 1.0) B (sortOrder: 2.0)
/// Drop between: A DROP HERE B
/// New sortOrder: 1.5 (midpoint of 1.0 and 2.0)
/// ```
///
/// **Edge cases:**
/// - First item in empty day: sortOrder = 1.0
/// - After last item: sortOrder = last + 1.0
/// - Before first item: sortOrder = first / 2.0
///
/// **Precision:** Double has ~15 significant digits. Even with millions of midpoint
/// insertions, precision remains sufficient. Example worst case:
/// - 50 insertions between 1.0 and 2.0: sortOrder 1.0000000000000009
/// - Still distinguishable and orderable
///
/// **Scanning logic:** We scan backwards and forwards from the drop position
/// to find adjacent custom items, stopping at day boundaries (headers, travel).
private func calculateSortOrder(at row: Int) -> Double {
let day = dayNumber(forRow: row)
// Find games row for this day (if any)
var gamesRow: Int? = nil
for i in 0..<flatItems.count {
if case .games(_, let d) = flatItems[i], d == day {
gamesRow = i
break
}
if case .dayHeader(let d, _) = flatItems[i], d > day {
break
}
}
let isBeforeGames = (gamesRow != nil && row <= gamesRow!)
func movableSortOrder(_ idx: Int) -> Double? {
guard idx >= 0 && idx < flatItems.count else { return nil }
switch flatItems[idx] {
case .customItem(let item):
return item.sortOrder
case .travel(let segment, _):
return findItineraryItem(for: segment)?.sortOrder
default:
return nil
}
}
func scanBackward(from start: Int) -> Double? {
var i = start
while i >= 0 {
if case .dayHeader(let d, _) = flatItems[i], d != day { break }
if case .dayHeader = flatItems[i] { break }
if case .games(_, let d) = flatItems[i], d == day { break }
if let v = movableSortOrder(i) {
if isBeforeGames {
if v < 0 { return v }
} else {
if v >= 0 { return v }
}
}
i -= 1
}
return nil
}
func scanForward(from start: Int) -> Double? {
var i = start
while i < flatItems.count {
if case .dayHeader(let d, _) = flatItems[i], d != day { break }
if case .dayHeader = flatItems[i] { break }
if case .games(_, let d) = flatItems[i], d == day { break }
if let v = movableSortOrder(i) {
if isBeforeGames {
if v < 0 { return v }
} else {
if v >= 0 { return v }
}
}
i += 1
}
return nil
}
if isBeforeGames {
let prev = scanBackward(from: row - 1)
let next = scanForward(from: row)
let upperBound: Double = 0.0 // games boundary
switch (prev, next) {
case (nil, nil):
return -1.0
case (let p?, nil):
return (p + upperBound) / 2.0
case (nil, let n?):
return n / 2.0
case (let p?, let n?):
return (p + n) / 2.0
}
} else {
let prev = scanBackward(from: row - 1) ?? 0.0
let next = scanForward(from: row)
switch next {
case nil:
return (prev == 0.0) ? 1.0 : (prev + 1.0)
case let n?:
return (prev + n) / 2.0
}
}
}
// 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)
Button(action: onAddTapped) {
HStack(spacing: Theme.Spacing.xs) {
Image(systemName: "plus.circle.fill")
.foregroundStyle(Theme.warmOrange.opacity(0.6))
Text("Add")
.font(.subheadline)
.foregroundStyle(Theme.textMuted(colorScheme))
}
}
.buttonStyle(.plain)
}
.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 {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm a" // "8:00 PM"
return formatter.string(from: richGame.game.dateTime)
}
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)
Text(richGame.stadium.name)
.font(.subheadline)
}
.foregroundStyle(Theme.textSecondary(colorScheme))
}
Spacer()
// Game time (prominently displayed)
Text(formattedTime)
.font(.title3)
.fontWeight(.medium)
.foregroundStyle(Theme.warmOrange)
}
.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()
}
.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)
}
}
// Address subtitle (shown only if present)
if let address = info.address, !address.isEmpty {
Text(address)
.font(.caption)
.foregroundStyle(Theme.textMuted(colorScheme))
.lineLimit(1)
}
}
}
Spacer()
// Chevron indicates this is tappable
Image(systemName: "chevron.right")
.foregroundStyle(.tertiary)
.font(.caption)
}
.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)
}
}