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:
921
SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift
Normal file
921
SportsTime/Features/Trip/Views/ItineraryReorderingLogic.swift
Normal file
@@ -0,0 +1,921 @@
|
||||
//
|
||||
// ItineraryReorderingLogic.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Pure functions for itinerary reordering logic.
|
||||
// Extracted from ItineraryTableViewController for testability.
|
||||
//
|
||||
// All functions in this enum are pure - they take inputs and return outputs
|
||||
// with no side effects, making them fully unit-testable without UIKit.
|
||||
//
|
||||
// SEMANTIC TRAVEL MODEL:
|
||||
// - Travel items are positioned semantically via (day, sortOrder), not structurally.
|
||||
// - Travel can appear before games (sortOrder < 0) or after games (sortOrder >= 0).
|
||||
// - The legacy `travelBefore` field on ItineraryDayData is IGNORED by flattenDays.
|
||||
// - All movable items (custom + travel) use the same day computation: backward scan to nearest dayHeader.
|
||||
//
|
||||
// COORDINATE SPACE CONVENTIONS:
|
||||
//
|
||||
// Two coordinate spaces exist during drag-drop operations:
|
||||
//
|
||||
// 1. ORIGINAL SPACE (flatItems indices)
|
||||
// - Row indices in the current flatItems array: 0..<flatItems.count
|
||||
// - Used by: DragZones (invalidRowIndices, validDropRows), UI highlighting
|
||||
// - Source row is always specified in original space
|
||||
//
|
||||
// 2. PROPOSED SPACE (UITableView post-removal)
|
||||
// - Row indices after sourceRow is removed from the array
|
||||
// - After removal: array has count-1 elements, valid insert positions are 0...(count-1)
|
||||
// - Used by: UITableView delegate methods, computeValidDestinationRowsProposed return value
|
||||
// - Proposed index N means: remove source, insert at position N in the remaining array
|
||||
//
|
||||
// FUNCTION REFERENCE:
|
||||
// - simulateMove: Takes PROPOSED index → returns post-move array + actual destination
|
||||
// - computeValidDestinationRowsProposed: Returns PROPOSED indices (for tableView delegate)
|
||||
// - calculateSortOrder: Takes row in POST-MOVE array (item already at destination)
|
||||
// - calculateTravelDragZones/calculateCustomItemDragZones: Return ORIGINAL indices
|
||||
//
|
||||
// COORDINATE CONVERSION:
|
||||
// - proposedToOriginal(proposed, sourceRow): Converts proposed → original
|
||||
// • If proposed >= sourceRow: return proposed + 1 (shift up past removed source)
|
||||
// • If proposed < sourceRow: return proposed (unchanged)
|
||||
// - originalToProposed(original, sourceRow): Converts original → proposed
|
||||
// • If original == sourceRow: return nil (source has no proposed equivalent)
|
||||
// • If original > sourceRow: return original - 1 (shift down)
|
||||
// • If original < sourceRow: return original (unchanged)
|
||||
//
|
||||
// WHY THIS MATTERS:
|
||||
// - DragZones are used for UI highlighting (which cells to dim/enable)
|
||||
// - UI highlighting operates on the visible table, which uses ORIGINAL indices
|
||||
// - But validation uses simulation, which operates in PROPOSED space
|
||||
// - Getting this wrong causes visual bugs (wrong rows highlighted) or logic bugs
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
// MARK: - Pure Functions for Itinerary Reordering
|
||||
|
||||
/// Container for all pure reordering logic.
|
||||
/// Using an enum (no cases) as a namespace for static functions.
|
||||
enum ItineraryReorderingLogic {
|
||||
|
||||
// MARK: - Row Flattening
|
||||
|
||||
/// Default sortOrder for travel when lookup returns nil.
|
||||
/// Travel defaults to after-games region (positive value).
|
||||
private static let defaultTravelSortOrder: Double = 1.0
|
||||
|
||||
/// Flattens hierarchical day data into a single array of row items.
|
||||
///
|
||||
/// **SEMANTIC MODEL**: This function ignores `day.travelBefore` entirely.
|
||||
/// Travel segments must be included in `day.items` with appropriate sortOrder.
|
||||
///
|
||||
/// For each day, rows are added in this order:
|
||||
/// 1. Day header - "Day N · Date"
|
||||
/// 2. Items with sortOrder < 0 (before games), sorted by sortOrder ascending
|
||||
/// 3. Games - all games for this day (grouped as one row)
|
||||
/// 4. Items with sortOrder >= 0 (after games), sorted by sortOrder ascending
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - days: Array of ItineraryDayData from the wrapper
|
||||
/// - findTravelSortOrder: Closure to look up sortOrder for a travel segment
|
||||
/// - Returns: Flattened array of ItineraryRowItem
|
||||
static func flattenDays(
|
||||
_ days: [ItineraryDayData],
|
||||
findTravelSortOrder: (TravelSegment) -> Double?
|
||||
) -> [ItineraryRowItem] {
|
||||
var flatItems: [ItineraryRowItem] = []
|
||||
|
||||
for day in days {
|
||||
// NOTE: day.travelBefore is IGNORED under semantic travel model.
|
||||
// Travel must be in day.items with a sortOrder to appear.
|
||||
|
||||
// 1. Day header (structural anchor)
|
||||
flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
|
||||
|
||||
// 2. Partition movable items around games boundary
|
||||
// Tuple includes tiebreaker for stable sorting when sortOrders are equal
|
||||
var beforeGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = []
|
||||
var afterGames: [(sortOrder: Double, tiebreaker: Int, item: ItineraryRowItem)] = []
|
||||
var insertionOrder = 0
|
||||
|
||||
for row in day.items {
|
||||
let sortOrder: Double
|
||||
let tiebreaker = insertionOrder
|
||||
insertionOrder += 1
|
||||
|
||||
switch row {
|
||||
case .customItem(let item):
|
||||
sortOrder = item.sortOrder
|
||||
|
||||
case .travel(let segment, _):
|
||||
if let so = findTravelSortOrder(segment) {
|
||||
sortOrder = so
|
||||
} else {
|
||||
// Travel without stored sortOrder gets a safe default.
|
||||
// Log a warning in debug builds - this shouldn't happen in production.
|
||||
#if DEBUG
|
||||
print("⚠️ flattenDays: Travel segment missing sortOrder: \(segment.fromLocation.name) → \(segment.toLocation.name). Using default: \(defaultTravelSortOrder)")
|
||||
#endif
|
||||
sortOrder = defaultTravelSortOrder
|
||||
}
|
||||
|
||||
case .games, .dayHeader:
|
||||
// These item types are not movable and handled separately.
|
||||
// Skip explicitly - games are added after partitioning.
|
||||
continue
|
||||
}
|
||||
|
||||
if sortOrder < 0 {
|
||||
beforeGames.append((sortOrder, tiebreaker, row))
|
||||
} else {
|
||||
afterGames.append((sortOrder, tiebreaker, row))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by sortOrder within each region, with stable tiebreaker
|
||||
beforeGames.sort { ($0.sortOrder, $0.tiebreaker) < ($1.sortOrder, $1.tiebreaker) }
|
||||
afterGames.sort { ($0.sortOrder, $0.tiebreaker) < ($1.sortOrder, $1.tiebreaker) }
|
||||
|
||||
flatItems.append(contentsOf: beforeGames.map { $0.item })
|
||||
|
||||
// 3. Games for this day (bundled as one row)
|
||||
if !day.games.isEmpty {
|
||||
flatItems.append(.games(day.games, dayNumber: day.dayNumber))
|
||||
}
|
||||
|
||||
// 4. Items after games
|
||||
flatItems.append(contentsOf: afterGames.map { $0.item })
|
||||
}
|
||||
|
||||
return flatItems
|
||||
}
|
||||
|
||||
// MARK: - Day Number Lookup
|
||||
|
||||
/// Finds which day a row at the given index belongs to.
|
||||
///
|
||||
/// Scans backwards from the row to find a `.dayHeader`.
|
||||
/// Returns 1 as fallback if no header is found.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - items: The flat array of row items
|
||||
/// - row: The row index to look up
|
||||
/// - Returns: The day number (1-indexed)
|
||||
static 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
|
||||
}
|
||||
|
||||
/// Finds the row index of the day header for a specific day number.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - items: The flat array of row items
|
||||
/// - day: The day number to find
|
||||
/// - Returns: The row index, or nil if not found
|
||||
static func dayHeaderRow(in items: [ItineraryRowItem], forDay day: Int) -> Int? {
|
||||
for (index, item) in items.enumerated() {
|
||||
if case .dayHeader(let dayNum, _) = item, dayNum == day {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Finds the row index of the travel segment on a specific day.
|
||||
///
|
||||
/// **SEMANTIC MODEL**: Does NOT use the embedded dayNumber in .travel().
|
||||
/// Instead, scans the day section (between dayHeader(day) and dayHeader(day+1))
|
||||
/// and returns the first travel row found.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - items: The flat array of row items
|
||||
/// - day: The day number to find
|
||||
/// - Returns: The row index, or nil if no travel on that day
|
||||
static func travelRow(in items: [ItineraryRowItem], forDay day: Int) -> Int? {
|
||||
// Find the day header row
|
||||
guard let headerRow = dayHeaderRow(in: items, forDay: day) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Scan forward until next day header, looking for travel
|
||||
for i in (headerRow + 1)..<items.count {
|
||||
switch items[i] {
|
||||
case .dayHeader:
|
||||
// Reached next day, no travel found
|
||||
return nil
|
||||
case .travel:
|
||||
return i
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Legacy version that uses embedded dayNumber (unreliable under semantic model).
|
||||
@available(*, deprecated, message: "Use travelRow(in:forDay:) which uses semantic day lookup")
|
||||
static func travelRowByEmbeddedDay(in items: [ItineraryRowItem], forDay day: Int) -> Int? {
|
||||
for (index, item) in items.enumerated() {
|
||||
if case .travel(_, let dayNum) = item, dayNum == day {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Determines which day a travel segment belongs to at a given row position.
|
||||
///
|
||||
/// **SEMANTIC MODEL**: Uses backward scan to find the nearest preceding dayHeader.
|
||||
/// This is consistent with how all movable items determine their day.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - row: The row index of the travel
|
||||
/// - items: The flat array of row items
|
||||
/// - Returns: The day number the travel belongs to
|
||||
static func dayForTravelAt(row: Int, in items: [ItineraryRowItem]) -> Int {
|
||||
// Semantic model: scan backward to find the day this item belongs to
|
||||
// (same logic as dayNumber)
|
||||
return dayNumber(in: items, forRow: row)
|
||||
}
|
||||
|
||||
// MARK: - Move Simulation
|
||||
|
||||
/// Result of simulating a move operation.
|
||||
struct SimulatedMove {
|
||||
let items: [ItineraryRowItem]
|
||||
let destinationRowInNewArray: Int
|
||||
let didMove: Bool // false if move was invalid/no-op
|
||||
}
|
||||
|
||||
/// Simulates UITableView move semantics with bounds safety.
|
||||
///
|
||||
/// UITableView moves work as: remove at sourceRow from ORIGINAL array,
|
||||
/// then insert at destinationProposedRow in the NEW array (post-removal coordinate space).
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - original: The original flat items array
|
||||
/// - sourceRow: Where the item is being moved from
|
||||
/// - destinationProposedRow: Where it's being moved to (in post-removal space)
|
||||
/// - Returns: The new array, the actual destination row, and whether the move occurred
|
||||
static func simulateMove(
|
||||
original: [ItineraryRowItem],
|
||||
sourceRow: Int,
|
||||
destinationProposedRow: Int
|
||||
) -> SimulatedMove {
|
||||
// Bounds safety: return original unchanged if sourceRow is invalid
|
||||
guard sourceRow >= 0 && sourceRow < original.count else {
|
||||
return SimulatedMove(items: original, destinationRowInNewArray: sourceRow, didMove: false)
|
||||
}
|
||||
|
||||
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, didMove: true)
|
||||
}
|
||||
|
||||
// MARK: - Coordinate Space Conversion
|
||||
|
||||
/// Converts a proposed destination index to the equivalent original index.
|
||||
///
|
||||
/// UITableView move semantics: remove at sourceRow first, then insert at proposed position.
|
||||
/// This means proposed indices >= sourceRow map to original indices + 1.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - proposed: Index in post-removal coordinate space
|
||||
/// - sourceRow: The row being moved (in original space)
|
||||
/// - Returns: Equivalent index in original coordinate space
|
||||
static func proposedToOriginal(_ proposed: Int, sourceRow: Int) -> Int {
|
||||
if proposed >= sourceRow {
|
||||
return proposed + 1
|
||||
} else {
|
||||
return proposed
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts an original index to the equivalent proposed destination index.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - original: Index in original coordinate space
|
||||
/// - sourceRow: The row being moved (in original space)
|
||||
/// - Returns: Equivalent index in post-removal coordinate space, or nil if original == sourceRow
|
||||
static func originalToProposed(_ original: Int, sourceRow: Int) -> Int? {
|
||||
if original == sourceRow {
|
||||
// The dragged item itself has no proposed equivalent
|
||||
return nil
|
||||
} else if original > sourceRow {
|
||||
return original - 1
|
||||
} else {
|
||||
return original
|
||||
}
|
||||
}
|
||||
|
||||
// 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:
|
||||
/// - Between items A(1.0) and B(2.0): new sortOrder = 1.5
|
||||
/// - First item in empty day: sortOrder = 1.0
|
||||
/// - After last item: sortOrder = last + 1.0
|
||||
/// - Before first item: sortOrder = first / 2.0
|
||||
///
|
||||
/// **Region classification**:
|
||||
/// - `row < gamesRow` => before-games region => sortOrder < 0
|
||||
/// - `row > gamesRow` => after-games region => sortOrder >= 0
|
||||
/// - `row == gamesRow` => treated as after-games (cannot drop ON games row)
|
||||
/// - No games on day => after-games region (sortOrder >= 0)
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - items: The flat array of row items (with moved item already in place)
|
||||
/// - row: The row index where the item was dropped
|
||||
/// - findTravelSortOrder: Closure to look up sortOrder for travel segments
|
||||
/// - Returns: The calculated sortOrder
|
||||
static func calculateSortOrder(
|
||||
in items: [ItineraryRowItem],
|
||||
at row: Int,
|
||||
findTravelSortOrder: (TravelSegment) -> Double?
|
||||
) -> Double {
|
||||
let day = dayNumber(in: items, forRow: row)
|
||||
|
||||
// Find games row for this day (if any)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Strict region classification:
|
||||
// - row < gamesRow => before-games (negative sortOrder)
|
||||
// - row >= gamesRow OR no games => after-games (positive sortOrder)
|
||||
let isBeforeGames: Bool
|
||||
if let gr = gamesRow {
|
||||
isBeforeGames = row < gr
|
||||
} else {
|
||||
isBeforeGames = false // No games means everything is "after games"
|
||||
}
|
||||
|
||||
/// Get sortOrder from a movable item (custom item or travel)
|
||||
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 findTravelSortOrder(segment)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan backward from start, stopping at boundaries, looking for movable items in the same region
|
||||
func scanBackward(from start: Int) -> Double? {
|
||||
var i = start
|
||||
while i >= 0 {
|
||||
// Stop at day boundaries
|
||||
if case .dayHeader(let d, _) = items[i] {
|
||||
if d != day { break }
|
||||
break // Stop at own day header too
|
||||
}
|
||||
// Stop at games boundary (don't cross into other region)
|
||||
if case .games(_, let d) = items[i], d == day { break }
|
||||
|
||||
if let v = movableSortOrder(i) {
|
||||
// Only return values in the correct region
|
||||
if isBeforeGames {
|
||||
if v < 0 { return v }
|
||||
} else {
|
||||
if v >= 0 { return v }
|
||||
}
|
||||
}
|
||||
i -= 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Scan forward from start, stopping at boundaries, looking for movable items in the same region
|
||||
func scanForward(from start: Int) -> Double? {
|
||||
var i = start
|
||||
while i < items.count {
|
||||
// Stop at day boundaries
|
||||
if case .dayHeader(let d, _) = items[i] {
|
||||
if d != day { break }
|
||||
break // Stop at any day header
|
||||
}
|
||||
// Stop at games boundary (don't cross into other region)
|
||||
if case .games(_, let d) = items[i], d == day { break }
|
||||
|
||||
if let v = movableSortOrder(i) {
|
||||
// Only return values in the correct region
|
||||
if isBeforeGames {
|
||||
if v < 0 { return v }
|
||||
} else {
|
||||
if v >= 0 { return v }
|
||||
}
|
||||
}
|
||||
i += 1
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if isBeforeGames {
|
||||
// Above games: sortOrder should be negative
|
||||
let prev = scanBackward(from: row - 1)
|
||||
let next = scanForward(from: row + 1)
|
||||
|
||||
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?):
|
||||
// First item before games: place it before the next item.
|
||||
// n should always be negative (scanForward filters for region).
|
||||
if n >= 0 {
|
||||
// This shouldn't happen - scanForward should only return negative values
|
||||
// in before-games region. Return safe default and assert in debug.
|
||||
assertionFailure("Before-games region has non-negative sortOrder: \(n)")
|
||||
return -1.0
|
||||
}
|
||||
// Place before n by subtracting 1.0 (simpler and more consistent than min(n/2, n-1))
|
||||
return n - 1.0
|
||||
case (let p?, let n?):
|
||||
return (p + n) / 2.0
|
||||
}
|
||||
} else {
|
||||
// Below games: sortOrder should be >= 0
|
||||
let prev = scanBackward(from: row - 1) ?? 0.0
|
||||
let next = scanForward(from: row + 1)
|
||||
|
||||
switch next {
|
||||
case nil:
|
||||
return (prev == 0.0) ? 1.0 : (prev + 1.0)
|
||||
case let n?:
|
||||
return (prev + n) / 2.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Valid Drop Computation
|
||||
|
||||
/// Computes all valid destination rows in **proposed** coordinate space.
|
||||
///
|
||||
/// For BOTH travel and custom items, we:
|
||||
/// 1. Simulate the move
|
||||
/// 2. Compute the resulting (day, sortOrder)
|
||||
/// 3. Validate with ItineraryConstraints
|
||||
///
|
||||
/// This ensures drop targets match what will actually be persisted.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - flatItems: The current flat items array
|
||||
/// - sourceRow: The row being moved
|
||||
/// - dragged: The item being dragged
|
||||
/// - travelValidRanges: Valid day ranges for travel segments
|
||||
/// - constraints: The constraint system for validation
|
||||
/// - findTravelItem: Closure to find ItineraryItem for a travel segment
|
||||
/// - makeTravelItem: Closure to create a default ItineraryItem for travel
|
||||
/// - findCustomItem: Closure to find ItineraryItem for a custom item row
|
||||
/// - findTravelSortOrder: Closure to find sortOrder for travel segments
|
||||
/// - Returns: Array of valid row indices in proposed coordinate space
|
||||
static func computeValidDestinationRowsProposed(
|
||||
flatItems: [ItineraryRowItem],
|
||||
sourceRow: Int,
|
||||
dragged: ItineraryRowItem,
|
||||
travelValidRanges: [String: ClosedRange<Int>],
|
||||
constraints: ItineraryConstraints?,
|
||||
findTravelItem: (TravelSegment) -> ItineraryItem?,
|
||||
makeTravelItem: (TravelSegment) -> ItineraryItem,
|
||||
findTravelSortOrder: @escaping (TravelSegment) -> Double?
|
||||
) -> [Int] {
|
||||
let maxProposed = max(0, flatItems.count - 1)
|
||||
guard maxProposed > 0 else { return [] }
|
||||
|
||||
switch dragged {
|
||||
case .customItem(let customItem):
|
||||
// Custom items use the same simulation+validation approach as travel
|
||||
guard let constraints = constraints else {
|
||||
// No constraint engine: allow all rows except 0 and day headers
|
||||
return (1...maxProposed).filter { proposedRow in
|
||||
let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
|
||||
guard simulated.didMove else { return false }
|
||||
// Don't allow dropping ON a day header
|
||||
if case .dayHeader = simulated.items[simulated.destinationRowInNewArray] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var valid: [Int] = []
|
||||
valid.reserveCapacity(maxProposed)
|
||||
|
||||
for proposedRow in 1...maxProposed {
|
||||
let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
|
||||
guard simulated.didMove else { continue }
|
||||
|
||||
let destRowInSim = simulated.destinationRowInNewArray
|
||||
|
||||
// Don't allow dropping ON a day header
|
||||
if case .dayHeader = simulated.items[destRowInSim] {
|
||||
continue
|
||||
}
|
||||
|
||||
let day = dayNumber(in: simulated.items, forRow: destRowInSim)
|
||||
let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder)
|
||||
|
||||
// Create a temporary item model with the computed position
|
||||
let testItem = ItineraryItem(
|
||||
id: customItem.id,
|
||||
tripId: customItem.tripId,
|
||||
day: day,
|
||||
sortOrder: sortOrder,
|
||||
kind: customItem.kind
|
||||
)
|
||||
|
||||
if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) {
|
||||
valid.append(proposedRow)
|
||||
}
|
||||
}
|
||||
|
||||
return valid
|
||||
|
||||
case .travel(let segment, _):
|
||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
let validDayRange = travelValidRanges[travelId]
|
||||
|
||||
// Use existing model if available, otherwise create a default
|
||||
let model = findTravelItem(segment) ?? makeTravelItem(segment)
|
||||
|
||||
guard let constraints = constraints else {
|
||||
// No constraint engine, allow all rows except 0 and day headers
|
||||
return (1...maxProposed).filter { proposedRow in
|
||||
let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
|
||||
guard simulated.didMove else { return false }
|
||||
if case .dayHeader = simulated.items[simulated.destinationRowInNewArray] {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
var valid: [Int] = []
|
||||
valid.reserveCapacity(maxProposed)
|
||||
|
||||
for proposedRow in 1...maxProposed {
|
||||
let simulated = simulateMove(original: flatItems, sourceRow: sourceRow, destinationProposedRow: proposedRow)
|
||||
guard simulated.didMove else { continue }
|
||||
|
||||
let destRowInSim = simulated.destinationRowInNewArray
|
||||
|
||||
// Don't allow dropping ON a day header
|
||||
if case .dayHeader = simulated.items[destRowInSim] {
|
||||
continue
|
||||
}
|
||||
|
||||
let day = dayNumber(in: simulated.items, forRow: destRowInSim)
|
||||
|
||||
// Check day range constraint (quick rejection)
|
||||
if let range = validDayRange, !range.contains(day) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check sortOrder constraint
|
||||
let sortOrder = calculateSortOrder(in: simulated.items, at: destRowInSim, findTravelSortOrder: findTravelSortOrder)
|
||||
|
||||
// Create a testItem with computed day/sortOrder (like custom items do)
|
||||
// This ensures constraints.isValidPosition sees the actual proposed position
|
||||
let testItem = ItineraryItem(
|
||||
id: model.id,
|
||||
tripId: model.tripId,
|
||||
day: day,
|
||||
sortOrder: sortOrder,
|
||||
kind: model.kind
|
||||
)
|
||||
|
||||
if constraints.isValidPosition(for: testItem, day: day, sortOrder: sortOrder) {
|
||||
valid.append(proposedRow)
|
||||
}
|
||||
}
|
||||
|
||||
return valid
|
||||
|
||||
default:
|
||||
// Day headers and games can't be moved
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drag Zones
|
||||
|
||||
/// Result of calculating drag zones for visual feedback.
|
||||
///
|
||||
/// **COORDINATE SPACE**: All indices are in ORIGINAL coordinate space (current flatItems indices).
|
||||
/// This is what the UI needs for highlighting rows before the move occurs.
|
||||
struct DragZones {
|
||||
/// Rows that should be dimmed/disabled in the UI (original indices)
|
||||
let invalidRowIndices: Set<Int>
|
||||
/// Rows where drop is allowed (original indices)
|
||||
let validDropRows: [Int]
|
||||
/// Game IDs that act as barriers for this drag
|
||||
let barrierGameIds: Set<String>
|
||||
}
|
||||
|
||||
/// Calculates drag zones for a travel segment using simulation+validation.
|
||||
///
|
||||
/// This ensures UI feedback matches what will actually be accepted on drop.
|
||||
/// Returns indices in ORIGINAL coordinate space for direct use in UI highlighting.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - segment: The travel segment being dragged
|
||||
/// - sourceRow: The current row of the travel (original index)
|
||||
/// - flatItems: The current flat items array
|
||||
/// - travelValidRanges: Valid day ranges for travel segments
|
||||
/// - constraints: The constraint system
|
||||
/// - findTravelItem: Closure to find ItineraryItem for travel
|
||||
/// - makeTravelItem: Closure to create a default ItineraryItem for travel
|
||||
/// - findTravelSortOrder: Closure to find sortOrder for travel
|
||||
/// - Returns: Drag zones with invalid rows, valid rows, and barrier game IDs (all in original space)
|
||||
static func calculateTravelDragZones(
|
||||
segment: TravelSegment,
|
||||
sourceRow: Int,
|
||||
flatItems: [ItineraryRowItem],
|
||||
travelValidRanges: [String: ClosedRange<Int>],
|
||||
constraints: ItineraryConstraints?,
|
||||
findTravelItem: (TravelSegment) -> ItineraryItem?,
|
||||
makeTravelItem: (TravelSegment) -> ItineraryItem,
|
||||
findTravelSortOrder: @escaping (TravelSegment) -> Double?
|
||||
) -> DragZones {
|
||||
// Get valid rows in PROPOSED coordinate space
|
||||
let validRowsProposed = computeValidDestinationRowsProposed(
|
||||
flatItems: flatItems,
|
||||
sourceRow: sourceRow,
|
||||
dragged: .travel(segment, dayNumber: 0), // dayNumber doesn't matter for validation
|
||||
travelValidRanges: travelValidRanges,
|
||||
constraints: constraints,
|
||||
findTravelItem: findTravelItem,
|
||||
makeTravelItem: makeTravelItem,
|
||||
findTravelSortOrder: findTravelSortOrder
|
||||
)
|
||||
|
||||
// Convert valid rows from proposed to original coordinate space
|
||||
let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) }
|
||||
let validSet = Set(validRowsOriginal)
|
||||
|
||||
// Compute invalid rows in original coordinate space
|
||||
var invalidRows = Set<Int>()
|
||||
for i in 0..<flatItems.count {
|
||||
if i == sourceRow {
|
||||
// The source row itself is neither valid nor invalid - it's being dragged
|
||||
continue
|
||||
}
|
||||
if !validSet.contains(i) {
|
||||
invalidRows.insert(i)
|
||||
}
|
||||
}
|
||||
|
||||
// Find barrier games using constraints
|
||||
var barrierGameIds = Set<String>()
|
||||
if let travelItem = findTravelItem(segment),
|
||||
let constraints = constraints {
|
||||
let barriers = constraints.barrierGames(for: travelItem)
|
||||
barrierGameIds = Set(barriers.compactMap { $0.gameId })
|
||||
}
|
||||
|
||||
return DragZones(
|
||||
invalidRowIndices: invalidRows,
|
||||
validDropRows: validRowsOriginal,
|
||||
barrierGameIds: barrierGameIds
|
||||
)
|
||||
}
|
||||
|
||||
/// Calculates drag zones for a custom item using simulation+validation.
|
||||
///
|
||||
/// This ensures UI feedback matches what will actually be accepted on drop.
|
||||
/// Returns indices in ORIGINAL coordinate space for direct use in UI highlighting.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - item: The custom item being dragged
|
||||
/// - sourceRow: The current row of the item (original index)
|
||||
/// - flatItems: The current flat items array
|
||||
/// - constraints: The constraint system
|
||||
/// - findTravelSortOrder: Closure to find sortOrder for travel
|
||||
/// - Returns: Drag zones with invalid rows and valid rows (all in original space)
|
||||
static func calculateCustomItemDragZones(
|
||||
item: ItineraryItem,
|
||||
sourceRow: Int,
|
||||
flatItems: [ItineraryRowItem],
|
||||
constraints: ItineraryConstraints?,
|
||||
findTravelSortOrder: @escaping (TravelSegment) -> Double?
|
||||
) -> DragZones {
|
||||
// Get valid rows in PROPOSED coordinate space
|
||||
let validRowsProposed = computeValidDestinationRowsProposed(
|
||||
flatItems: flatItems,
|
||||
sourceRow: sourceRow,
|
||||
dragged: .customItem(item),
|
||||
travelValidRanges: [:], // Custom items don't use travel ranges
|
||||
constraints: constraints,
|
||||
findTravelItem: { _ in nil },
|
||||
makeTravelItem: { _ in
|
||||
// This won't be called for custom items
|
||||
fatalError("makeTravelItem called for custom item")
|
||||
},
|
||||
findTravelSortOrder: findTravelSortOrder
|
||||
)
|
||||
|
||||
// Convert valid rows from proposed to original coordinate space
|
||||
let validRowsOriginal = validRowsProposed.map { proposedToOriginal($0, sourceRow: sourceRow) }
|
||||
let validSet = Set(validRowsOriginal)
|
||||
|
||||
// Compute invalid rows in original coordinate space
|
||||
var invalidRows = Set<Int>()
|
||||
for i in 0..<flatItems.count {
|
||||
if i == sourceRow {
|
||||
// The source row itself is neither valid nor invalid - it's being dragged
|
||||
continue
|
||||
}
|
||||
if !validSet.contains(i) {
|
||||
invalidRows.insert(i)
|
||||
}
|
||||
}
|
||||
|
||||
return DragZones(
|
||||
invalidRowIndices: invalidRows,
|
||||
validDropRows: validRowsOriginal,
|
||||
barrierGameIds: [] // No barrier highlighting for custom items
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Legacy Compatibility
|
||||
|
||||
/// Legacy version of calculateTravelDragZones that doesn't require sourceRow.
|
||||
/// Uses day-range-based calculation only.
|
||||
///
|
||||
/// - Note: Prefer the version with sourceRow for accurate validation.
|
||||
@available(*, deprecated, message: "Use calculateTravelDragZones(segment:sourceRow:...) for accurate validation")
|
||||
static func calculateTravelDragZones(
|
||||
segment: TravelSegment,
|
||||
flatItems: [ItineraryRowItem],
|
||||
travelValidRanges: [String: ClosedRange<Int>],
|
||||
constraints: ItineraryConstraints?,
|
||||
findTravelItem: (TravelSegment) -> ItineraryItem?
|
||||
) -> DragZones {
|
||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
|
||||
guard let validRange = travelValidRanges[travelId] else {
|
||||
return DragZones(invalidRowIndices: [], validDropRows: [], barrierGameIds: [])
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Find barrier games using constraints
|
||||
var barrierGameIds = Set<String>()
|
||||
if let travelItem = findTravelItem(segment),
|
||||
let constraints = constraints {
|
||||
let barriers = constraints.barrierGames(for: travelItem)
|
||||
barrierGameIds = Set(barriers.compactMap { $0.gameId })
|
||||
}
|
||||
|
||||
return DragZones(
|
||||
invalidRowIndices: invalidRows,
|
||||
validDropRows: validRows,
|
||||
barrierGameIds: barrierGameIds
|
||||
)
|
||||
}
|
||||
|
||||
/// Legacy version of calculateCustomItemDragZones that doesn't require sourceRow.
|
||||
///
|
||||
/// - Note: Prefer the version with sourceRow for accurate validation.
|
||||
@available(*, deprecated, message: "Use calculateCustomItemDragZones(item:sourceRow:...) for accurate validation")
|
||||
static func calculateCustomItemDragZones(
|
||||
item: ItineraryItem,
|
||||
flatItems: [ItineraryRowItem]
|
||||
) -> DragZones {
|
||||
var invalidRows = Set<Int>()
|
||||
var validRows: [Int] = []
|
||||
|
||||
for (index, rowItem) in flatItems.enumerated() {
|
||||
if case .dayHeader = rowItem {
|
||||
invalidRows.insert(index)
|
||||
} else {
|
||||
validRows.append(index)
|
||||
}
|
||||
}
|
||||
|
||||
return DragZones(
|
||||
invalidRowIndices: invalidRows,
|
||||
validDropRows: validRows,
|
||||
barrierGameIds: []
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Utility Functions
|
||||
|
||||
/// Finds the nearest value in a sorted array using binary search.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - sorted: A sorted array of integers
|
||||
/// - target: The target value to find the nearest match for
|
||||
/// - Returns: The nearest value, or nil if array is empty
|
||||
static func nearestValue(in sorted: [Int], to target: Int) -> Int? {
|
||||
guard !sorted.isEmpty else { return nil }
|
||||
|
||||
var low = 0
|
||||
var high = sorted.count
|
||||
|
||||
// Binary search for insertion point
|
||||
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?):
|
||||
// Both exist, return the closer one
|
||||
return (target - b) <= (a - target) ? b : a
|
||||
case let (b?, nil):
|
||||
return b
|
||||
case let (nil, a?):
|
||||
return a
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculates target destination with constraint snapping.
|
||||
///
|
||||
/// If the proposed row is valid, returns it. Otherwise, snaps to nearest valid row.
|
||||
///
|
||||
/// **COORDINATE SPACE**: This function expects all indices in PROPOSED coordinate space.
|
||||
/// The caller must ensure validDestinationRows comes from computeValidDestinationRowsProposed.
|
||||
///
|
||||
/// **UX RULE**: Row 0 is forbidden (always a day header). If proposedRow <= 0, it's clamped to 1.
|
||||
/// This is a UX-level rule, not a semantic constraint - day headers cannot receive drops.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - proposedRow: The user's proposed drop position (in proposed coordinate space)
|
||||
/// - validDestinationRows: Pre-computed valid rows from computeValidDestinationRowsProposed
|
||||
/// - sourceRow: The original row (fallback if no valid destination found)
|
||||
/// - Returns: The target row to use (in proposed coordinate space)
|
||||
///
|
||||
/// - Note: Uses O(n) contains check. For repeated calls, consider passing a Set instead.
|
||||
/// However, validDestinationRows is typically small (< 50 items), so this is fine.
|
||||
static func calculateTargetRow(
|
||||
proposedRow: Int,
|
||||
validDestinationRows: [Int],
|
||||
sourceRow: Int
|
||||
) -> Int {
|
||||
// UX rule: forbid dropping at absolute top (row 0 is always a day header)
|
||||
var row = proposedRow
|
||||
if row <= 0 { row = 1 }
|
||||
|
||||
// If already valid, use it
|
||||
if validDestinationRows.contains(row) {
|
||||
return row
|
||||
}
|
||||
|
||||
// Snap to nearest valid destination (validDestinationRows must be sorted for binary search)
|
||||
return nearestValue(in: validDestinationRows, to: row) ?? sourceRow
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user