diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index 710a6f8..eb09dfd 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -2,38 +2,268 @@ // ItineraryTableViewController.swift // SportsTime // -// Native UITableViewController for fluid itinerary reordering +// 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 - "Day 3 · Sunday, Mar 8" +// 3. ADD BUTTON - "+ Add" (always immediately after header) +// 4. GAMES - All games for this day (city label + cards) +// 5. CUSTOM ITEMS - User-added items sorted by sortOrder +// +// Visual example: +// ┌─────────────────────────────────────┐ +// │ 🚗 Detroit → Milwaukee (327mi) │ ← Travel (arrives Day 3) +// ├─────────────────────────────────────┤ +// │ Day 3 · Sunday, Mar 8 │ ← Day Header +// ├─────────────────────────────────────┤ +// │ + Add │ ← 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 (structural anchors) +// - Add buttons (always after day header) +// - 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: ((CustomItineraryItem) -> Void)? +// - Called when user taps custom item row +// - Parent presents edit sheet +// +// onCustomItemDeleted: ((CustomItineraryItem) -> 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 +// - CustomItineraryItem.swift: Domain model with (day, sortOrder) positioning +// - CustomItemService.swift: CloudKit persistence for custom items +// +// ═══════════════════════════════════════════════════════════════════════════════ import UIKit import SwiftUI // MARK: - Data Models -/// Represents a single day in the itinerary +/// 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 + let id: Int // dayNumber (1-indexed) let dayNumber: Int let date: Date let games: [RichGame] - var items: [ItineraryRowItem] // Items that appear AFTER the day header - var travelBefore: TravelSegment? // Travel that appears BEFORE this day's header + var 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 row item in the itinerary +/// 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, games: [RichGame]) - case travel(TravelSegment, dayNumber: Int) // dayNumber = the day this travel is associated with - case customItem(CustomItineraryItem) - case addButton(day: Int) // Simplified - just needs day + case dayHeader(dayNumber: Int, date: Date) // Fixed: structural anchor + case games([RichGame], dayNumber: Int) // Fixed: games are trip-determined + case travel(TravelSegment, dayNumber: Int) // Reorderable: within valid range + case customItem(CustomItineraryItem) // Reorderable: anywhere + case addButton(day: Int) // Fixed: always after day header + /// 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, _, _): + 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)" @@ -42,9 +272,11 @@ enum ItineraryRowItem: Identifiable, Equatable { } } + /// Controls whether UITableView shows the drag reorder handle. + /// Day headers, add buttons, and games are structural - users can't move them. var isReorderable: Bool { switch self { - case .dayHeader, .addButton: + case .dayHeader, .addButton, .games: return false case .travel, .customItem: return true @@ -75,6 +307,7 @@ final class ItineraryTableViewController: UITableViewController { // Cell reuse identifiers private let dayHeaderCellId = "DayHeaderCell" + private let gamesCellId = "GamesCell" private let travelCellId = "TravelCell" private let customItemCellId = "CustomItemCell" private let addButtonCellId = "AddButtonCell" @@ -90,6 +323,20 @@ final class ItineraryTableViewController: UITableViewController { 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 @@ -97,23 +344,36 @@ final class ItineraryTableViewController: UITableViewController { tableView.estimatedRowHeight = 80 tableView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 40, right: 0) - // Enable editing mode for reordering + // 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 - // Register cells + // 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) tableView.register(UITableViewCell.self, forCellReuseIdentifier: addButtonCellId) } + /// 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) { - // Wrap in a container to help with sizing 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), @@ -125,77 +385,139 @@ final class ItineraryTableViewController: UITableViewController { 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() - // Prevent infinite loops + // 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) - // Only update if height changed significantly (avoid floating-point jitter) + // 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 - "Day N · Date" + /// 3. Add button - always immediately after header + /// 4. Games - all games for this day (grouped as one row) + /// 5. 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 after header means it's always accessible, even on rest days + /// - 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 func reloadData(days: [ItineraryDayData], travelValidRanges: [String: ClosedRange]) { self.travelValidRanges = travelValidRanges - // Build flat list: for each day, travel BEFORE header, then header, then items flatItems = [] for day in days { - // Travel that arrives on this day (appears BEFORE the day header) + // 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)) } - // Day header (with games inside) - flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date, games: day.games)) + // 2. Day header (structural anchor - cannot be moved or deleted) + flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date)) - // Items for this day (custom items, add buttons) + // 3. Add button (always immediately after header for easy access) + flatItems.append(.addButton(day: day.dayNumber)) + + // 4. Games for this day (bundled as one row, not individually reorderable) + // Games are determined by the trip planning engine, not user-movable + if !day.games.isEmpty { + flatItems.append(.games(day.games, dayNumber: day.dayNumber)) + } + + // 5. Custom items (user-added, already sorted by sortOrder in day.items) + // We filter because day.items may contain other row types from wrapper for item in day.items { - flatItems.append(item) + if case .customItem = item { + flatItems.append(item) + } } } tableView.reloadData() } - // MARK: - Helper to find day for row + // 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 { - // Scan backwards to find the nearest day header for i in stride(from: row, through: 0, by: -1) { - if case .dayHeader(let dayNum, _, _) = flatItems[i] { + if case .dayHeader(let dayNum, _) = flatItems[i] { return dayNum } - // If we hit travel, it belongs to the NEXT day header + // Travel stores its destination day, so if we hit travel first, + // we're conceptually still in that travel's destination day if case .travel(_, let dayNum) = flatItems[i] { return dayNum } } - return 1 + return 1 // Fallback to day 1 if no header found (shouldn't happen) } + /// 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 { + 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 { @@ -208,6 +530,8 @@ final class ItineraryTableViewController: UITableViewController { // 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 } @@ -215,13 +539,28 @@ final class ItineraryTableViewController: UITableViewController { 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 games): + case .dayHeader(let dayNumber, let date): let cell = tableView.dequeueReusableCell(withIdentifier: dayHeaderCellId, for: indexPath) - configureDayHeaderCell(cell, dayNumber: dayNumber, date: date, games: games) + 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, _): @@ -241,69 +580,120 @@ final class ItineraryTableViewController: UITableViewController { } } - // MARK: - Reordering + // 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, add buttons, 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 + /// + /// **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] - // Remove from source + // Update our in-memory data model flatItems.remove(at: sourceIndexPath.row) - - // Insert at destination flatItems.insert(item, at: destinationIndexPath.row) - // Notify callbacks + // Notify parent view of the change switch item { case .travel(let segment, _): - // Find which day this travel is now associated with + // Travel's "day" is the day it arrives on (the next day header after its position) let newDay = dayForTravelAt(row: destinationIndexPath.row) let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" onTravelMoved?(travelId, newDay) 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) onCustomItemMoved?(customItem.id, destinationDay, sortOrder) default: - break + break // Day headers, games, add buttons can't be moved } } - /// For travel, find the day it's now before (the next day header after this position) + /// 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.. IndexPath { let item = flatItems[sourceIndexPath.row] var proposedRow = proposedDestinationIndexPath.row - // Can't move to position 0 (before everything) + // Global constraint: can't move to position 0 (before all content) if proposedRow == 0 { proposedRow = 1 } - // Ensure proposedRow is in bounds + // Ensure within bounds proposedRow = min(proposedRow, flatItems.count - 1) switch item { case .travel(let segment, _): + // TRAVEL CONSTRAINT LOGIC + // Travel can only be on certain days (must finish games in departure city, + // must arrive by game time in destination city) + let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())" guard let validRange = travelValidRanges[travelId] else { @@ -311,12 +701,11 @@ final class ItineraryTableViewController: UITableViewController { return proposedDestinationIndexPath } - // Find which day travel would be associated with at proposed position - // Travel is associated with the NEXT day header after its position + // Figure out which day the user is trying to drop onto var proposedDay = dayForTravelAtProposed(row: proposedRow, excluding: sourceIndexPath.row) print("🎯 Drag travel: proposedRow=\(proposedRow), proposedDay=\(proposedDay), validRange=\(validRange)") - // Constrain to valid range + // Clamp to valid range - this is what creates the "snap" effect if proposedDay < validRange.lowerBound { print("🎯 Clamping up: \(proposedDay) -> \(validRange.lowerBound)") proposedDay = validRange.lowerBound @@ -325,13 +714,14 @@ final class ItineraryTableViewController: UITableViewController { proposedDay = validRange.upperBound } - // Find the correct row: right before the day header for proposedDay + // Convert day number back to row position (right before that day's header) if let headerRow = dayHeaderRow(forDay: proposedDay) { - // Travel goes right before the day header - // But account for the fact that we're removing from source first var targetRow = headerRow + // Account for the fact that the source row will be removed first + // This is a UITableView quirk - the destination index is calculated + // as if the source has already been removed if sourceIndexPath.row < headerRow { - targetRow -= 1 // Source removal shifts everything up + targetRow -= 1 } print("🎯 Final target: day=\(proposedDay), headerRow=\(headerRow), targetRow=\(targetRow)") return IndexPath(row: max(0, targetRow), section: 0) @@ -340,61 +730,92 @@ final class ItineraryTableViewController: UITableViewController { return proposedDestinationIndexPath case .customItem: - // Custom items can go anywhere except: - // - Before position 0 - // - Onto a day header (go after it instead) + // CUSTOM ITEM CONSTRAINT LOGIC + // Custom items are flexible - they can go anywhere within the itinerary, + // but we prevent dropping in places that would be confusing + + // Don't drop ON a day header - go after it instead if proposedRow < flatItems.count, case .dayHeader = flatItems[proposedRow] { return IndexPath(row: proposedRow + 1, section: 0) } - // Can't go before travel that's at the start + + // Don't drop before travel at the start of a day + // (would visually appear before the travel card, which is confusing) if proposedRow < flatItems.count, case .travel = flatItems[proposedRow] { - // Find the day header after this travel and go after it + // Find the day header after this travel and drop after the header for i in proposedRow.. Int { - // Look forward from the proposed row to find the next day header - // Skip the excluded row (the item being moved) + // Scan forward, skipping the item being moved for i in row.. 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: - Selection + // MARK: - Row Selection + /// Handles taps on rows. + /// + /// We only respond to taps on: + /// - **Custom items:** Opens edit sheet via `onCustomItemTapped` + /// - **Add buttons:** Opens add sheet for that day via `onAddButtonTapped` + /// + /// Day headers, games, and travel segments don't respond to taps. override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + // Immediately deselect to remove highlight tableView.deselectRow(at: indexPath, animated: true) let item = flatItems[indexPath.row] @@ -407,15 +828,23 @@ final class ItineraryTableViewController: UITableViewController { onAddButtonTapped?(day) default: - break + break // Other row types don't respond to taps } } - // MARK: - Context Menu + // 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 } @@ -433,73 +862,99 @@ final class ItineraryTableViewController: UITableViewController { } } - // MARK: - Helper Methods + // MARK: - Sort Order Calculation - /// Calculate the sortOrder for an item dropped at the given row position - /// Uses midpoint insertion: if between sortOrder 1.0 and 2.0, returns 1.5 + /// 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 { - // Find adjacent custom items to calculate midpoint var prevSortOrder: Double? var nextSortOrder: Double? - // Scan backwards for previous custom item in same day + // SCAN BACKWARDS to find previous custom item in this day for i in stride(from: row - 1, through: 0, by: -1) { switch flatItems[i] { case .customItem(let item): + // Found a custom item - use its sortOrder prevSortOrder = item.sortOrder - break case .dayHeader, .travel: - // Hit a boundary - no previous item in this section + // Hit a day boundary - no previous custom item in this day break - case .addButton: + case .games, .addButton: + // Skip non-custom, non-boundary items continue } + // Stop scanning once we found an item or hit a boundary if prevSortOrder != nil { break } - // If we hit dayHeader or travel, stop scanning if case .dayHeader = flatItems[i] { break } if case .travel = flatItems[i] { break } } - // Scan forwards for next custom item in same day + // SCAN FORWARDS to find next custom item in this day for i in row..: UIViewControllerRepresent // Travel before this day (travel is stored on the destination day) let travelBefore: TravelSegment? = travelByDay[dayNum] + // ONE Add button per day - always first after day header + items.append(ItineraryRowItem.addButton(day: dayNum)) + // Custom items for this day - simply filter by day and sort by sortOrder let dayItems = customItems.filter { $0.day == dayNum } .sorted { $0.sortOrder < $1.sortOrder } @@ -167,9 +170,6 @@ struct ItineraryTableViewWrapper: UIViewControllerRepresent items.append(ItineraryRowItem.customItem(item)) } - // ONE Add button per day - items.append(ItineraryRowItem.addButton(day: dayNum)) - let dayData = ItineraryDayData( id: dayNum, dayNumber: dayNum, diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index 6a7213b..8bc8fff 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -837,14 +837,14 @@ struct TripDetailView: View { // Custom items for this day (sorted by sortOrder) if allowCustomItems { + // Add button first - always right after day header + sections.append(.addButton(day: dayNum)) + let dayItems = customItems.filter { $0.day == dayNum } .sorted { $0.sortOrder < $1.sortOrder } for item in dayItems { sections.append(.customItem(item)) } - - // One add button per day - sections.append(.addButton(day: dayNum)) } }