refactor(itinerary): extract reordering logic into pure functions
Extract all itinerary reordering logic from ItineraryTableViewController into ItineraryReorderingLogic.swift for testability. Key changes: - Add flattenDays, dayNumber, travelRow, simulateMove pure functions - Add calculateSortOrder with proper region classification (before/after games) - Add computeValidDestinationRowsProposed with simulation+validation pattern - Add coordinate space conversion helpers (proposedToOriginal, originalToProposed) - Fix DragZones coordinate space mismatch (was mixing proposed/original indices) - Add comprehensive documentation of coordinate space conventions Test coverage includes: - Row flattening order and semantic travel model - Sort order calculation for before/after games regions - Travel constraints validation - DragZones coordinate space correctness - Coordinate conversion helpers - Edge cases (empty days, multi-day trips) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -462,20 +462,8 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
|
||||
/// Transforms hierarchical day data into a flat row list and refreshes the table.
|
||||
///
|
||||
/// This is the core data transformation method. It takes structured `[ItineraryDayData]`
|
||||
/// from the wrapper and flattens it into `[ItineraryRowItem]` for UITableView display.
|
||||
///
|
||||
/// **Flattening Algorithm:**
|
||||
/// For each day, rows are added in this exact order:
|
||||
/// 1. Travel (if arriving this day) - appears visually BEFORE the day header
|
||||
/// 2. Day header (with Add button) - "Day N · Date" + tappable Add button
|
||||
/// 3. Games - all games for this day (grouped as one row)
|
||||
/// 4. Custom items - user-added items, already sorted by sortOrder
|
||||
///
|
||||
/// **Why this order matters:**
|
||||
/// - Travel before header creates visual grouping: "you travel, then you're on day N"
|
||||
/// - Add button is part of header row (can't drag items between header and Add)
|
||||
/// - Games before custom items preserves the "trip-determined, then user-added" hierarchy
|
||||
/// Delegates to `ItineraryReorderingLogic.flattenDays` for the pure transformation,
|
||||
/// then updates the table view.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - days: Array of ItineraryDayData from ItineraryTableViewWrapper
|
||||
@@ -489,99 +477,33 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
self.travelValidRanges = travelValidRanges
|
||||
self.allItineraryItems = itineraryItems
|
||||
self.tripDayCount = days.count
|
||||
|
||||
|
||||
// Rebuild constraints with new data
|
||||
self.constraints = ItineraryConstraints(tripDayCount: tripDayCount, items: itineraryItems)
|
||||
|
||||
flatItems = []
|
||||
|
||||
for day in days {
|
||||
// 1. Travel that arrives on this day (renders BEFORE the day header)
|
||||
// Example: "Detroit → Milwaukee" appears above "Day 3" header
|
||||
if let travel = day.travelBefore {
|
||||
flatItems.append(.travel(travel, dayNumber: day.dayNumber))
|
||||
}
|
||||
|
||||
// 2. Day header with Add button (structural anchor - cannot be moved or deleted)
|
||||
// Add button is embedded in the header to prevent items being dragged between them
|
||||
flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
|
||||
|
||||
// 3. Movable items (travel + custom) split around games boundary.
|
||||
// Convention: sortOrder < 0 renders ABOVE games; sortOrder >= 0 renders BELOW games.
|
||||
var beforeGames: [ItineraryRowItem] = []
|
||||
var afterGames: [ItineraryRowItem] = []
|
||||
|
||||
for row in day.items {
|
||||
let so: Double?
|
||||
switch row {
|
||||
case .customItem(let item):
|
||||
so = item.sortOrder
|
||||
case .travel(let segment, _):
|
||||
// Travel sortOrder is stored in itineraryItems (kind: .travel)
|
||||
so = findItineraryItem(for: segment)?.sortOrder
|
||||
default:
|
||||
so = nil
|
||||
}
|
||||
|
||||
guard let sortOrder = so else { continue }
|
||||
if sortOrder < 0 {
|
||||
beforeGames.append(row)
|
||||
} else {
|
||||
afterGames.append(row)
|
||||
}
|
||||
}
|
||||
|
||||
flatItems.append(contentsOf: beforeGames)
|
||||
|
||||
// 4. Games for this day (bundled as one row, not individually reorderable)
|
||||
if !day.games.isEmpty {
|
||||
flatItems.append(.games(day.games, dayNumber: day.dayNumber))
|
||||
}
|
||||
|
||||
flatItems.append(contentsOf: afterGames)
|
||||
|
||||
// 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
|
||||
|
||||
// MARK: - Row-to-Day Mapping Helpers (delegating to pure functions)
|
||||
|
||||
/// Finds which day a row at the given index belongs to.
|
||||
///
|
||||
/// Scans backwards from the row to find either:
|
||||
/// - A `.dayHeader` → that's the day
|
||||
/// - A `.travel` → uses the dayNumber stored in the travel item
|
||||
///
|
||||
/// This is used when a custom item is dropped to determine its new day.
|
||||
private func dayNumber(forRow row: Int) -> Int {
|
||||
for i in stride(from: row, through: 0, by: -1) {
|
||||
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
return 1
|
||||
ItineraryReorderingLogic.dayNumber(in: flatItems, forRow: row)
|
||||
}
|
||||
|
||||
|
||||
/// Finds the row index of the day header for a specific day number.
|
||||
/// Returns nil if no header exists for that day (shouldn't happen in valid data).
|
||||
private func dayHeaderRow(forDay day: Int) -> Int? {
|
||||
for (index, item) in flatItems.enumerated() {
|
||||
if case .dayHeader(let dayNum, _) = item, dayNum == day {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return nil
|
||||
ItineraryReorderingLogic.dayHeaderRow(in: flatItems, forDay: day)
|
||||
}
|
||||
|
||||
|
||||
/// Finds the row index of the travel segment arriving on a specific day.
|
||||
/// Returns nil if no travel arrives on that day.
|
||||
private func travelRow(forDay day: Int) -> Int? {
|
||||
for (index, item) in flatItems.enumerated() {
|
||||
if case .travel(_, let dayNum) = item, dayNum == day {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return nil
|
||||
ItineraryReorderingLogic.travelRow(in: flatItems, forDay: day)
|
||||
}
|
||||
|
||||
// MARK: - Drag State Management
|
||||
@@ -647,83 +569,27 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
}
|
||||
|
||||
/// Calculates invalid zones for a travel segment drag.
|
||||
///
|
||||
/// Travel items have hard constraints:
|
||||
/// - Can't leave before finishing games in departure city
|
||||
/// - Must arrive by the first game in destination city
|
||||
///
|
||||
/// Invalid zones are any rows outside the valid day range.
|
||||
/// Delegates to pure function and applies results to instance state.
|
||||
private func calculateTravelDragZones(segment: TravelSegment) {
|
||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
|
||||
// Get valid day range from pre-calculated ranges
|
||||
guard let validRange = travelValidRanges[travelId] else {
|
||||
invalidRowIndices = []
|
||||
validDropRows = []
|
||||
barrierGameIds = []
|
||||
return
|
||||
}
|
||||
|
||||
// Calculate invalid and valid row indices based on day range
|
||||
// Pre-calculate ALL valid positions for stable drag behavior
|
||||
var invalidRows = Set<Int>()
|
||||
var validRows: [Int] = []
|
||||
|
||||
for (index, rowItem) in flatItems.enumerated() {
|
||||
let dayNum: Int
|
||||
switch rowItem {
|
||||
case .dayHeader(let d, _):
|
||||
dayNum = d
|
||||
case .games(_, let d):
|
||||
dayNum = d
|
||||
case .travel(_, let d):
|
||||
dayNum = d
|
||||
case .customItem(let item):
|
||||
dayNum = item.day
|
||||
}
|
||||
|
||||
if validRange.contains(dayNum) {
|
||||
validRows.append(index)
|
||||
} else {
|
||||
invalidRows.insert(index)
|
||||
}
|
||||
}
|
||||
|
||||
invalidRowIndices = invalidRows
|
||||
validDropRows = validRows // Already sorted since we iterate in order
|
||||
|
||||
// Find barrier games using ItineraryConstraints
|
||||
if let travelItem = findItineraryItem(for: segment),
|
||||
let constraints = constraints {
|
||||
let barriers = constraints.barrierGames(for: travelItem)
|
||||
barrierGameIds = Set(barriers.compactMap { $0.gameId })
|
||||
} else {
|
||||
barrierGameIds = []
|
||||
}
|
||||
let zones = ItineraryReorderingLogic.calculateTravelDragZones(
|
||||
segment: segment,
|
||||
flatItems: flatItems,
|
||||
travelValidRanges: travelValidRanges,
|
||||
constraints: constraints,
|
||||
findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) }
|
||||
)
|
||||
invalidRowIndices = zones.invalidRowIndices
|
||||
validDropRows = zones.validDropRows
|
||||
barrierGameIds = zones.barrierGameIds
|
||||
}
|
||||
|
||||
|
||||
/// Calculates invalid zones for a custom item drag.
|
||||
///
|
||||
/// Custom items can go on any day, but we mark certain positions as
|
||||
/// less ideal (e.g., directly on day headers or before travel).
|
||||
/// Delegates to pure function and applies results to instance state.
|
||||
private func calculateCustomItemDragZones(item: ItineraryItem) {
|
||||
// Custom items are flexible - can go anywhere except ON day headers
|
||||
// Pre-calculate ALL valid row indices for stable drag behavior
|
||||
var invalidRows = Set<Int>()
|
||||
var validRows: [Int] = []
|
||||
|
||||
for (index, rowItem) in flatItems.enumerated() {
|
||||
if case .dayHeader = rowItem {
|
||||
invalidRows.insert(index)
|
||||
} else {
|
||||
// All non-header rows are valid drop targets
|
||||
validRows.append(index)
|
||||
}
|
||||
}
|
||||
|
||||
invalidRowIndices = invalidRows
|
||||
validDropRows = validRows // Already sorted since we iterate in order
|
||||
barrierGameIds = [] // No barrier highlighting for custom items
|
||||
let zones = ItineraryReorderingLogic.calculateCustomItemDragZones(item: item, flatItems: flatItems)
|
||||
invalidRowIndices = zones.invalidRowIndices
|
||||
validDropRows = zones.validDropRows
|
||||
barrierGameIds = zones.barrierGameIds
|
||||
}
|
||||
|
||||
/// Finds the ItineraryItem model for a travel segment.
|
||||
@@ -914,52 +780,16 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
}
|
||||
|
||||
/// Determines which day a travel segment belongs to at a given row position.
|
||||
///
|
||||
/// Travel conceptually "arrives on" a day - it appears BEFORE that day's header.
|
||||
/// So we scan FORWARD from the travel's position to find the next day header.
|
||||
///
|
||||
/// Example:
|
||||
/// ```
|
||||
/// [0] Travel: Detroit → Milwaukee ← If travel is here...
|
||||
/// [1] Day 3 header ← ...it belongs to Day 3
|
||||
/// ```
|
||||
private func dayForTravelAt(row: Int) -> Int {
|
||||
// Scan forward to find the day header this travel precedes
|
||||
for i in row..<flatItems.count {
|
||||
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
// Fallback: scan backwards to find any day header
|
||||
for i in stride(from: flatItems.count - 1, through: 0, by: -1) {
|
||||
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
return 1 // Ultimate fallback
|
||||
ItineraryReorderingLogic.dayForTravelAt(row: row, in: flatItems)
|
||||
}
|
||||
|
||||
/// Called DURING a drag to validate and potentially modify the drop position.
|
||||
///
|
||||
/// This is the core drag constraint logic. UITableView calls this continuously
|
||||
/// as the user drags, allowing us to redirect the drop to a valid position.
|
||||
///
|
||||
/// **Key behaviors:**
|
||||
///
|
||||
/// **Travel segments:** Constrained to their valid day range. If user tries to
|
||||
/// drag outside the range, we snap to the nearest valid day. This prevents
|
||||
/// impossible itineraries (e.g., arriving before you've left).
|
||||
///
|
||||
/// **Custom items:** Can go almost anywhere, but we prevent:
|
||||
/// - Dropping ON a day header (redirect to after header)
|
||||
/// - Dropping BEFORE travel at start of day (redirect to after header)
|
||||
///
|
||||
/// **Fixed items:** Day headers, games, add buttons return their source position
|
||||
/// (they never actually drag since canMoveRowAt returns false).
|
||||
///
|
||||
/// **Drag State Management:**
|
||||
/// - First call: Initializes drag state, calculates invalid zones, triggers pickup haptic
|
||||
/// - Subsequent calls: Checks zone transitions for haptic feedback
|
||||
/// 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
|
||||
@@ -970,256 +800,59 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath,
|
||||
toProposedIndexPath proposedDestinationIndexPath: IndexPath
|
||||
) -> IndexPath {
|
||||
|
||||
|
||||
let sourceRow = sourceIndexPath.row
|
||||
let item = flatItems[sourceRow]
|
||||
|
||||
// Drag start detection
|
||||
|
||||
// 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
|
||||
|
||||
// Avoid absolute top (keeps UX sane)
|
||||
if proposedRow <= 0 { proposedRow = 1 }
|
||||
|
||||
proposedRow = min(max(0, proposedRow), max(0, flatItems.count - 1))
|
||||
|
||||
proposedRow = min(max(1, proposedRow), max(0, flatItems.count - 1))
|
||||
|
||||
// Haptics / visuals
|
||||
checkZoneTransition(at: proposedRow)
|
||||
|
||||
// If already valid, allow it.
|
||||
if validDestinationRowsProposed.contains(proposedRow) {
|
||||
return IndexPath(row: proposedRow, section: 0)
|
||||
}
|
||||
|
||||
// Snap to nearest valid destination (proposed coordinate space)
|
||||
guard let snapped = nearestValue(in: validDestinationRowsProposed, to: proposedRow) else {
|
||||
return sourceIndexPath
|
||||
}
|
||||
return IndexPath(row: snapped, section: 0)
|
||||
|
||||
// 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 (semantic day + sortOrder)
|
||||
|
||||
/// Nearest value in a sorted Int array to the target (binary search).
|
||||
private func nearestValue(in sorted: [Int], to target: Int) -> Int? {
|
||||
guard !sorted.isEmpty else { return nil }
|
||||
var low = 0
|
||||
var high = sorted.count
|
||||
while low < high {
|
||||
let mid = (low + high) / 2
|
||||
if sorted[mid] < target { low = mid + 1 } else { high = mid }
|
||||
}
|
||||
let after = (low < sorted.count) ? sorted[low] : nil
|
||||
let before = (low > 0) ? sorted[low - 1] : nil
|
||||
|
||||
switch (before, after) {
|
||||
case let (b?, a?):
|
||||
return (target - b) <= (a - target) ? b : a
|
||||
case let (b?, nil):
|
||||
return b
|
||||
case let (nil, a?):
|
||||
return a
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Computes all valid destination rows in **proposed** coordinate space (UIKit's coordinate space during drag).
|
||||
/// We simulate the move and validate using semantic constraints: (day, sortOrder).
|
||||
// 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] {
|
||||
// Proposed rows are in the array AFTER removing the source row.
|
||||
let maxProposed = max(0, flatItems.count - 1)
|
||||
guard maxProposed > 0 else { return [] }
|
||||
|
||||
switch dragged {
|
||||
case .customItem:
|
||||
// Custom items can go basically anywhere (including before headers = "between days").
|
||||
// Keep row 0 blocked.
|
||||
return Array(1...maxProposed)
|
||||
|
||||
case .travel(let segment, _):
|
||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
let validDayRange = travelValidRanges[travelId]
|
||||
|
||||
// Use existing itinerary model if available (for constraints)
|
||||
let model: ItineraryItem = findItineraryItem(for: segment) ?? ItineraryItem(
|
||||
tripId: allItineraryItems.first?.tripId ?? UUID(),
|
||||
day: 1,
|
||||
sortOrder: 0,
|
||||
kind: .travel(TravelInfo(fromCity: segment.fromLocation.name, toCity: segment.toLocation.name, distanceMeters: segment.distanceMeters, durationSeconds: segment.durationSeconds))
|
||||
)
|
||||
|
||||
guard let constraints else {
|
||||
// If no constraint engine, allow all rows (except 0)
|
||||
return Array(1...maxProposed)
|
||||
}
|
||||
|
||||
var valid: [Int] = []
|
||||
valid.reserveCapacity(maxProposed)
|
||||
|
||||
for proposedRow in 1...maxProposed {
|
||||
let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
|
||||
let destRowInSim = simulated.destinationRowInNewArray
|
||||
let day = dayNumber(in: simulated.items, forRow: destRowInSim)
|
||||
if let r = validDayRange, !r.contains(day) {
|
||||
continue
|
||||
}
|
||||
let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim)
|
||||
if constraints.isValidPosition(for: model, day: day, sortOrder: sortOrder) {
|
||||
valid.append(proposedRow)
|
||||
}
|
||||
}
|
||||
|
||||
return valid
|
||||
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private struct SimulatedMove {
|
||||
let items: [ItineraryRowItem]
|
||||
let destinationRowInNewArray: Int
|
||||
}
|
||||
|
||||
/// Simulate UITableView move semantics: remove at sourceRow from ORIGINAL array, then insert at destinationProposedRow
|
||||
/// in the NEW array (post-removal coordinate space).
|
||||
private func simulateMove(original: [ItineraryRowItem], sourceRow: Int, destinationProposedRow: Int) -> SimulatedMove {
|
||||
var items = original
|
||||
let moving = items.remove(at: sourceRow)
|
||||
let clampedDest = min(max(0, destinationProposedRow), items.count)
|
||||
items.insert(moving, at: clampedDest)
|
||||
return SimulatedMove(items: items, destinationRowInNewArray: clampedDest)
|
||||
}
|
||||
|
||||
/// Day number lookup within an arbitrary flat array (used during simulation).
|
||||
private func dayNumber(in items: [ItineraryRowItem], forRow row: Int) -> Int {
|
||||
guard !items.isEmpty else { return 1 }
|
||||
let clamped = min(max(0, row), items.count - 1)
|
||||
for i in stride(from: clamped, through: 0, by: -1) {
|
||||
if case .dayHeader(let dayNum, _) = items[i] {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
/// Calculates sortOrder for insertion at a row within an arbitrary flat array.
|
||||
/// Uses the same convention as the main function:
|
||||
/// - sortOrder < 0 => above games
|
||||
/// - sortOrder >= 0 => below games
|
||||
private func calculateSortOrder(in items: [ItineraryRowItem], at row: Int) -> Double {
|
||||
let day = dayNumber(in: items, forRow: row)
|
||||
|
||||
// Find games row for this day in the provided items
|
||||
var gamesRow: Int? = nil
|
||||
for i in 0..<items.count {
|
||||
if case .games(_, let d) = items[i], d == day {
|
||||
gamesRow = i
|
||||
break
|
||||
}
|
||||
if case .dayHeader(let d, _) = items[i], d > day {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let isBeforeGames = (gamesRow != nil && row <= gamesRow!)
|
||||
|
||||
func movableSortOrder(_ idx: Int) -> Double? {
|
||||
guard idx >= 0 && idx < items.count else { return nil }
|
||||
switch items[idx] {
|
||||
case .customItem(let item):
|
||||
return item.sortOrder
|
||||
case .travel(let segment, _):
|
||||
return findItineraryItem(for: segment)?.sortOrder
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func scanBackward(from start: Int) -> Double? {
|
||||
var i = start
|
||||
while i >= 0 {
|
||||
if case .dayHeader(let d, _) = items[i], d != day { break }
|
||||
if case .dayHeader = items[i] { break }
|
||||
if case .games(_, let d) = items[i], d == day { break }
|
||||
if let v = movableSortOrder(i) {
|
||||
if isBeforeGames {
|
||||
if v < 0 { return v }
|
||||
} else {
|
||||
if v >= 0 { return v }
|
||||
}
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanForward(from start: Int) -> Double? {
|
||||
var i = start
|
||||
while i < items.count {
|
||||
if case .dayHeader(let d, _) = items[i], d != day { break }
|
||||
if case .dayHeader = items[i] { break }
|
||||
if case .games(_, let d) = items[i], d == day { break }
|
||||
if let v = movableSortOrder(i) {
|
||||
if isBeforeGames {
|
||||
if v < 0 { return v }
|
||||
} else {
|
||||
if v >= 0 { return v }
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if isBeforeGames {
|
||||
let prev = scanBackward(from: row - 1)
|
||||
let next = scanForward(from: row)
|
||||
|
||||
let upperBound: Double = 0.0
|
||||
switch (prev, next) {
|
||||
case (nil, nil):
|
||||
return -1.0
|
||||
case (let p?, nil):
|
||||
return (p + upperBound) / 2.0
|
||||
case (nil, let n?):
|
||||
return n / 2.0
|
||||
case (let p?, let n?):
|
||||
return (p + n) / 2.0
|
||||
}
|
||||
} else {
|
||||
let prev = scanBackward(from: row - 1) ?? 0.0
|
||||
let next = scanForward(from: row)
|
||||
|
||||
switch next {
|
||||
case nil:
|
||||
return (prev == 0.0) ? 1.0 : (prev + 1.0)
|
||||
case let n?:
|
||||
return (prev + n) / 2.0
|
||||
}
|
||||
}
|
||||
}
|
||||
private func dayForTravelAtProposed(row: Int, excluding: Int) -> Int {
|
||||
// Scan forward, skipping the item being moved
|
||||
for i in row..<flatItems.count {
|
||||
if i == excluding { continue }
|
||||
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
// Fallback: scan backwards
|
||||
for i in stride(from: flatItems.count - 1, through: 0, by: -1) {
|
||||
if i == excluding { continue }
|
||||
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
return 1
|
||||
ItineraryReorderingLogic.computeValidDestinationRowsProposed(
|
||||
flatItems: flatItems,
|
||||
sourceRow: sourceRow,
|
||||
dragged: dragged,
|
||||
travelValidRanges: travelValidRanges,
|
||||
constraints: constraints,
|
||||
findTravelItem: { [weak self] segment in self?.findItineraryItem(for: segment) },
|
||||
makeTravelItem: { [weak self] segment in
|
||||
ItineraryItem(
|
||||
tripId: self?.allItineraryItems.first?.tripId ?? UUID(),
|
||||
day: 1,
|
||||
sortOrder: 0,
|
||||
kind: .travel(TravelInfo(
|
||||
fromCity: segment.fromLocation.name,
|
||||
toCity: segment.toLocation.name,
|
||||
distanceMeters: segment.distanceMeters,
|
||||
durationSeconds: segment.durationSeconds
|
||||
))
|
||||
)
|
||||
},
|
||||
findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Editing Style Configuration
|
||||
@@ -1297,120 +930,15 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
}
|
||||
|
||||
// MARK: - Sort Order Calculation
|
||||
|
||||
|
||||
/// Calculates the sortOrder for an item dropped at the given row position.
|
||||
///
|
||||
/// Uses **midpoint insertion** algorithm to avoid renumbering existing items:
|
||||
///
|
||||
/// ```
|
||||
/// Existing items: A (sortOrder: 1.0) B (sortOrder: 2.0)
|
||||
/// Drop between: A ← DROP HERE → B
|
||||
/// New sortOrder: 1.5 (midpoint of 1.0 and 2.0)
|
||||
/// ```
|
||||
///
|
||||
/// **Edge cases:**
|
||||
/// - First item in empty day: sortOrder = 1.0
|
||||
/// - After last item: sortOrder = last + 1.0
|
||||
/// - Before first item: sortOrder = first / 2.0
|
||||
///
|
||||
/// **Precision:** Double has ~15 significant digits. Even with millions of midpoint
|
||||
/// insertions, precision remains sufficient. Example worst case:
|
||||
/// - 50 insertions between 1.0 and 2.0: sortOrder ≈ 1.0000000000000009
|
||||
/// - Still distinguishable and orderable
|
||||
///
|
||||
/// **Scanning logic:** We scan backwards and forwards from the drop position
|
||||
/// to find adjacent custom items, stopping at day boundaries (headers, travel).
|
||||
/// Delegates to pure function with closure for travel sortOrder lookup.
|
||||
private func calculateSortOrder(at row: Int) -> Double {
|
||||
let day = dayNumber(forRow: row)
|
||||
|
||||
// Find games row for this day (if any)
|
||||
var gamesRow: Int? = nil
|
||||
for i in 0..<flatItems.count {
|
||||
if case .games(_, let d) = flatItems[i], d == day {
|
||||
gamesRow = i
|
||||
break
|
||||
}
|
||||
if case .dayHeader(let d, _) = flatItems[i], d > day {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let isBeforeGames = (gamesRow != nil && row <= gamesRow!)
|
||||
|
||||
func movableSortOrder(_ idx: Int) -> Double? {
|
||||
guard idx >= 0 && idx < flatItems.count else { return nil }
|
||||
switch flatItems[idx] {
|
||||
case .customItem(let item):
|
||||
return item.sortOrder
|
||||
case .travel(let segment, _):
|
||||
return findItineraryItem(for: segment)?.sortOrder
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func scanBackward(from start: Int) -> Double? {
|
||||
var i = start
|
||||
while i >= 0 {
|
||||
if case .dayHeader(let d, _) = flatItems[i], d != day { break }
|
||||
if case .dayHeader = flatItems[i] { break }
|
||||
if case .games(_, let d) = flatItems[i], d == day { break }
|
||||
if let v = movableSortOrder(i) {
|
||||
if isBeforeGames {
|
||||
if v < 0 { return v }
|
||||
} else {
|
||||
if v >= 0 { return v }
|
||||
}
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanForward(from start: Int) -> Double? {
|
||||
var i = start
|
||||
while i < flatItems.count {
|
||||
if case .dayHeader(let d, _) = flatItems[i], d != day { break }
|
||||
if case .dayHeader = flatItems[i] { break }
|
||||
if case .games(_, let d) = flatItems[i], d == day { break }
|
||||
if let v = movableSortOrder(i) {
|
||||
if isBeforeGames {
|
||||
if v < 0 { return v }
|
||||
} else {
|
||||
if v >= 0 { return v }
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if isBeforeGames {
|
||||
let prev = scanBackward(from: row - 1)
|
||||
let next = scanForward(from: row)
|
||||
|
||||
let upperBound: Double = 0.0 // games boundary
|
||||
switch (prev, next) {
|
||||
case (nil, nil):
|
||||
return -1.0
|
||||
case (let p?, nil):
|
||||
return (p + upperBound) / 2.0
|
||||
case (nil, let n?):
|
||||
return n / 2.0
|
||||
case (let p?, let n?):
|
||||
return (p + n) / 2.0
|
||||
}
|
||||
} else {
|
||||
let prev = scanBackward(from: row - 1) ?? 0.0
|
||||
let next = scanForward(from: row)
|
||||
|
||||
switch next {
|
||||
case nil:
|
||||
return (prev == 0.0) ? 1.0 : (prev + 1.0)
|
||||
case let n?:
|
||||
return (prev + n) / 2.0
|
||||
}
|
||||
}
|
||||
ItineraryReorderingLogic.calculateSortOrder(
|
||||
in: flatItems,
|
||||
at: row,
|
||||
findTravelSortOrder: { [weak self] segment in self?.findItineraryItem(for: segment)?.sortOrder }
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Cell Configuration
|
||||
|
||||
Reference in New Issue
Block a user