diff --git a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift index 01eb66c..b09f569 100644 --- a/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift +++ b/SportsTime/Features/Trip/Views/ItineraryTableViewController.swift @@ -328,9 +328,16 @@ final class ItineraryTableViewController: UITableViewController { /// The item currently being dragged (nil when no drag active) private var draggingItem: ItineraryRowItem? - /// Row indices that are invalid drop targets for the current drag + /// 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 = [] + /// 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] = [] + /// IDs of games that act as barriers for the current travel drag (for gold highlighting) private var barrierGameIds: Set = [] @@ -605,7 +612,9 @@ final class ItineraryTableViewController: UITableViewController { /// 4. Removes visual dimming and highlighting private func endDrag() { draggingItem = nil + dragTargetDay = nil invalidRowIndices = [] + validDropRows = [] barrierGameIds = [] isInValidZone = true @@ -629,41 +638,40 @@ final class ItineraryTableViewController: UITableViewController { // Get valid day range from pre-calculated ranges guard let validRange = travelValidRanges[travelId] else { invalidRowIndices = [] + validDropRows = [] barrierGameIds = [] return } - // Calculate invalid row indices (rows outside valid day range) + // Calculate invalid and valid row indices based on day range + // Pre-calculate ALL valid positions for stable drag behavior var invalidRows = Set() + var validRows: [Int] = [] + for (index, rowItem) in flatItems.enumerated() { + let dayNum: Int switch rowItem { - case .dayHeader(let dayNum, _): - // Day header rows for days outside valid range are invalid drop targets - // (travel would appear BEFORE this header, making it belong to this day) - if !validRange.contains(dayNum) { - invalidRows.insert(index) - } - case .games(_, let dayNum): - // Games on days outside valid range are invalid - if !validRange.contains(dayNum) { - invalidRows.insert(index) - } - case .travel(_, let dayNum): - // Other travel rows on days outside valid range - if !validRange.contains(dayNum) { - invalidRows.insert(index) - } + case .dayHeader(let d, _): + dayNum = d + case .games(_, let d): + dayNum = d + case .travel(_, let d): + dayNum = d case .customItem(let item): - // Custom items on days outside valid range - if !validRange.contains(item.day) { - invalidRows.insert(index) - } + 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 - // First, find or create the ItineraryItem for this travel if let travelItem = findItineraryItem(for: segment), let constraints = constraints { let barriers = constraints.barrierGames(for: travelItem) @@ -678,15 +686,22 @@ final class ItineraryTableViewController: UITableViewController { /// 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 - no day restrictions - // But we can mark day headers as invalid since items shouldn't drop ON them + // Custom items are flexible - can go anywhere except ON day headers + // Pre-calculate ALL valid row indices for stable drag behavior var invalidRows = Set() + 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 } @@ -859,6 +874,16 @@ final class ItineraryTableViewController: UITableViewController { // 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: @@ -924,7 +949,7 @@ final class ItineraryTableViewController: UITableViewController { // DRAG START DETECTION // The first call to this method indicates drag has started. - // Initialize drag state, calculate invalid zones, and trigger pickup haptic. + // Initialize drag state, calculate valid/invalid zones, and trigger pickup haptic. if draggingItem == nil { beginDrag(at: sourceIndexPath) } @@ -941,75 +966,10 @@ final class ItineraryTableViewController: UITableViewController { checkZoneTransition(at: proposedRow) 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 { - print("No valid range for travel: \(travelId)") - return proposedDestinationIndexPath - } - - // Figure out which day the user is trying to drop onto - var proposedDay = dayForTravelAtProposed(row: proposedRow, excluding: sourceIndexPath.row) - - // Clamp to valid range - this is what creates the "snap" effect - if proposedDay < validRange.lowerBound { - proposedDay = validRange.lowerBound - } else if proposedDay > validRange.upperBound { - proposedDay = validRange.upperBound - } - - // Convert day number back to row position (right before that day's header) - if let headerRow = dayHeaderRow(forDay: proposedDay) { - 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 - } - return IndexPath(row: max(0, targetRow), section: 0) - } - - return proposedDestinationIndexPath - - case .customItem(let customItem): - // 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 - - // Use ItineraryConstraints to validate position - let proposedDay = dayNumber(forRow: proposedRow) - let proposedSortOrder = calculateSortOrder(at: proposedRow) - - if let constraints = constraints { - if !constraints.isValidPosition(for: customItem, day: proposedDay, sortOrder: proposedSortOrder) { - // If position is invalid, try to find a valid nearby position - // For now, just prevent dropping on day headers - } - } - - // 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) - } - - // 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 drop after the header - for i in proposedRow.. IndexPath { + // Fast path: if proposed row is valid, return it immediately + // This is the key to preventing bouncing - no recalculation needed + if validDropRows.contains(proposedRow) { + return IndexPath(row: proposedRow, section: 0) + } + + // Proposed row is invalid - find the nearest valid row + guard !validDropRows.isEmpty else { + return IndexPath(row: proposedRow, section: 0) + } + + // Binary search for insertion point + var low = 0 + var high = validDropRows.count + + while low < high { + let mid = (low + high) / 2 + if validDropRows[mid] < proposedRow { + low = mid + 1 + } else { + high = mid + } + } + + // low is now the insertion point - check neighbors to find nearest + let before = low > 0 ? validDropRows[low - 1] : nil + let after = low < validDropRows.count ? validDropRows[low] : nil + + let nearest: Int + if let b = before, let a = after { + // Both neighbors exist - pick the closer one + nearest = (proposedRow - b) <= (a - proposedRow) ? b : a + } else if let b = before { + nearest = b + } else if let a = after { + nearest = a + } else { + nearest = proposedRow // Fallback (shouldn't happen) + } + + return IndexPath(row: nearest, section: 0) + } + /// Calculates which day a travel segment would belong to if dropped at a proposed position. /// /// Similar to `dayForTravelAt`, but used during the drag (before the move completes).