Files
Sportstime/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift
2026-02-19 23:49:29 -06:00

1478 lines
67 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: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] = []
/// Canonical trip travel segment index keyed by TravelSegment UUID.
private var travelSegmentIndices: [UUID: Int] = [:]
/// 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] = [],
travelSegmentIndices: [UUID: Int] = [:]
) {
self.travelValidRanges = travelValidRanges
self.allItineraryItems = itineraryItems
self.travelSegmentIndices = travelSegmentIndices
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: - Marketing Video Auto-Scroll
#if DEBUG
private var displayLink: CADisplayLink?
private var scrollStartTime: CFTimeInterval = 0
private var scrollDuration: CFTimeInterval = 6.0
private var scrollStartOffset: CGFloat = 0
private var scrollEndOffset: CGFloat = 0
func scrollToBottomAnimated(delay: TimeInterval = 1.5, duration: TimeInterval = 6.0) {
guard UserDefaults.standard.bool(forKey: "marketingVideoMode") else { return }
self.scrollDuration = duration
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
let maxOffset = self.tableView.contentSize.height - self.tableView.bounds.height + self.tableView.contentInset.bottom
guard maxOffset > 0 else { return }
self.scrollStartOffset = self.tableView.contentOffset.y
self.scrollEndOffset = maxOffset
self.scrollStartTime = CACurrentMediaTime()
let link = CADisplayLink(target: self, selector: #selector(self.marketingScrollTick))
link.add(to: .main, forMode: .common)
self.displayLink = link
}
}
@objc private func marketingScrollTick() {
let elapsed = CACurrentMediaTime() - scrollStartTime
let t = min(elapsed / scrollDuration, 1.0)
// Ease-in-out curve
let eased = t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t
let newOffset = scrollStartOffset + (scrollEndOffset - scrollStartOffset) * eased
tableView.contentOffset = CGPoint(x: 0, y: newOffset)
if t >= 1.0 {
displayLink?.invalidate()
displayLink = nil
}
}
#endif
// 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 sourceRow = flatItems.firstIndex { item in
if case .travel(let rowSegment, _) = item {
return rowSegment.id == segment.id
}
return false
} ?? 0
let zones = ItineraryReorderingLogic.calculateTravelDragZones(
segment: segment,
sourceRow: sourceRow,
flatItems: flatItems,
travelValidRanges: travelValidRanges,
constraints: constraints,
findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) },
makeTravelItem: { [weak self] segment in
let segIdx = self?.travelSegmentIndices[segment.id]
return ItineraryItem(
tripId: self?.allItineraryItems.first?.tripId ?? UUID(),
day: 1,
sortOrder: 0,
kind: .travel(
TravelInfo(
segment: segment,
segmentIndex: segIdx
)
)
)
},
findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder }
)
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 sourceRow = flatItems.firstIndex { row in
if case .customItem(let current) = row {
return current.id == item.id
}
return false
} ?? 0
let zones = ItineraryReorderingLogic.calculateCustomItemDragZones(
item: item,
sourceRow: sourceRow,
flatItems: flatItems,
constraints: constraints,
findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder }
)
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? {
if let segIdx = travelSegmentIndices[segment.id] {
if let exact = allItineraryItems.first(where: { item in
guard case .travel(let info) = item.kind else { return false }
return info.segmentIndex == segIdx
}) {
return exact
}
}
// 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.matches(segment: segment)
}
// If only one match, return it
if matches.count <= 1 { return matches.first }
if let segIdx = travelSegmentIndices[segment.id] {
if let indexed = matches.first(where: { $0.travelInfo?.segmentIndex == segIdx }) {
return indexed
}
}
return matches.first(where: { $0.travelInfo?.segmentIndex != nil }) ?? matches.first
}
/// 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 = travelSegmentIndices[segment.id]
?? findItineraryItem(for: segment)?.travelInfo?.segmentIndex
?? 0
let from = TravelInfo.normalizeCityName(segment.fromLocation.name)
let to = TravelInfo.normalizeCityName(segment.toLocation.name)
let travelId = "travel:\(segIdx):\(from)->\(to)"
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
let segIdx = self?.travelSegmentIndices[segment.id]
return ItineraryItem(
tripId: self?.allItineraryItems.first?.tripId ?? UUID(),
day: 1,
sortOrder: 0,
kind: .travel(TravelInfo(
segment: segment,
segmentIndex: segIdx,
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
cell.accessibilityIdentifier = "tripDetail.customItem"
}
}
// 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")
.accessibilityIdentifier("tripDetail.addItemButton")
}
.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)
}
}