wip
This commit is contained in:
@@ -2,7 +2,12 @@
|
|||||||
"permissions": {
|
"permissions": {
|
||||||
"allow": [
|
"allow": [
|
||||||
"Skill(superpowers:brainstorming)",
|
"Skill(superpowers:brainstorming)",
|
||||||
"Skill(superpowers:writing-plans)"
|
"Skill(superpowers:writing-plans)",
|
||||||
|
"Skill(superpowers:using-git-worktrees)",
|
||||||
|
"Skill(superpowers:subagent-driven-development)",
|
||||||
|
"Bash(git add:*)",
|
||||||
|
"Bash(git commit:*)",
|
||||||
|
"WebSearch"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,7 +295,7 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
var colorScheme: ColorScheme = .dark
|
var colorScheme: ColorScheme = .dark
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onTravelMoved: ((String, Int) -> Void)? // travelId, newDay
|
var onTravelMoved: ((String, Int, Double) -> Void)? // travelId, newDay
|
||||||
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
||||||
var onCustomItemTapped: ((ItineraryItem) -> Void)?
|
var onCustomItemTapped: ((ItineraryItem) -> Void)?
|
||||||
var onCustomItemDeleted: ((ItineraryItem) -> Void)?
|
var onCustomItemDeleted: ((ItineraryItem) -> Void)?
|
||||||
@@ -338,6 +338,11 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
/// Using a sorted array enables O(log n) nearest-neighbor lookup
|
/// Using a sorted array enables O(log n) nearest-neighbor lookup
|
||||||
private var validDropRows: [Int] = []
|
private var validDropRows: [Int] = []
|
||||||
|
|
||||||
|
/// Valid destination rows in *proposed* coordinate space (after removing the source row).
|
||||||
|
/// Precomputed at drag start by simulating the move and validating semantic constraints.
|
||||||
|
private var validDestinationRowsProposed: [Int] = []
|
||||||
|
|
||||||
|
|
||||||
/// IDs of games that act as barriers for the current travel drag (for gold highlighting)
|
/// IDs of games that act as barriers for the current travel drag (for gold highlighting)
|
||||||
private var barrierGameIds: Set<String> = []
|
private var barrierGameIds: Set<String> = []
|
||||||
|
|
||||||
@@ -501,19 +506,39 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
// Add button is embedded in the header to prevent items being dragged between them
|
// Add button is embedded in the header to prevent items being dragged between them
|
||||||
flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
|
flatItems.append(.dayHeader(dayNumber: day.dayNumber, date: day.date))
|
||||||
|
|
||||||
// 3. Games for this day (bundled as one row, not individually reorderable)
|
// 3. Movable items (travel + custom) split around games boundary.
|
||||||
// Games are determined by the trip planning engine, not user-movable
|
// 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 {
|
if !day.games.isEmpty {
|
||||||
flatItems.append(.games(day.games, dayNumber: day.dayNumber))
|
flatItems.append(.games(day.games, dayNumber: day.dayNumber))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Custom items (user-added, already sorted by sortOrder in day.items)
|
flatItems.append(contentsOf: afterGames)
|
||||||
// We filter because day.items may contain other row types from wrapper
|
|
||||||
for item in day.items {
|
|
||||||
if case .customItem = item {
|
|
||||||
flatItems.append(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tableView.reloadData()
|
tableView.reloadData()
|
||||||
@@ -533,13 +558,8 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
if case .dayHeader(let dayNum, _) = flatItems[i] {
|
||||||
return dayNum
|
return dayNum
|
||||||
}
|
}
|
||||||
// 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.
|
/// Finds the row index of the day header for a specific day number.
|
||||||
@@ -615,6 +635,7 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
dragTargetDay = nil
|
dragTargetDay = nil
|
||||||
invalidRowIndices = []
|
invalidRowIndices = []
|
||||||
validDropRows = []
|
validDropRows = []
|
||||||
|
validDestinationRowsProposed = []
|
||||||
barrierGameIds = []
|
barrierGameIds = []
|
||||||
isInValidZone = true
|
isInValidZone = true
|
||||||
|
|
||||||
@@ -865,10 +886,11 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
// Notify parent view of the change
|
// Notify parent view of the change
|
||||||
switch item {
|
switch item {
|
||||||
case .travel(let segment, _):
|
case .travel(let segment, _):
|
||||||
// Travel's "day" is the day it arrives on (the next day header after its position)
|
// Travel is positioned within a day using sortOrder (can be before/after games)
|
||||||
let newDay = dayForTravelAt(row: destinationIndexPath.row)
|
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
|
||||||
|
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
|
||||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||||
onTravelMoved?(travelId, newDay)
|
onTravelMoved?(travelId, destinationDay, sortOrder)
|
||||||
|
|
||||||
case .customItem(let customItem):
|
case .customItem(let customItem):
|
||||||
// Calculate the new day and sortOrder for the dropped position
|
// Calculate the new day and sortOrder for the dropped position
|
||||||
@@ -943,98 +965,245 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
/// - sourceIndexPath: Where the item is being dragged FROM
|
/// - sourceIndexPath: Where the item is being dragged FROM
|
||||||
/// - proposedDestinationIndexPath: Where the user is trying to drop
|
/// - proposedDestinationIndexPath: Where the user is trying to drop
|
||||||
/// - Returns: The actual destination (may differ from proposed)
|
/// - Returns: The actual destination (may differ from proposed)
|
||||||
override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
|
override func tableView(
|
||||||
let item = flatItems[sourceIndexPath.row]
|
_ tableView: UITableView,
|
||||||
var proposedRow = proposedDestinationIndexPath.row
|
targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath,
|
||||||
|
toProposedIndexPath proposedDestinationIndexPath: IndexPath
|
||||||
|
) -> IndexPath {
|
||||||
|
|
||||||
// DRAG START DETECTION
|
let sourceRow = sourceIndexPath.row
|
||||||
// The first call to this method indicates drag has started.
|
let item = flatItems[sourceRow]
|
||||||
// Initialize drag state, calculate valid/invalid zones, and trigger pickup haptic.
|
|
||||||
|
// Drag start detection
|
||||||
if draggingItem == nil {
|
if draggingItem == nil {
|
||||||
beginDrag(at: sourceIndexPath)
|
beginDrag(at: sourceIndexPath)
|
||||||
|
validDestinationRowsProposed = computeValidDestinationRowsProposed(sourceRow: sourceRow, dragged: item)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Global constraint: can't move to position 0 (before all content)
|
var proposedRow = proposedDestinationIndexPath.row
|
||||||
if proposedRow == 0 {
|
|
||||||
proposedRow = 1
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure within bounds
|
// Avoid absolute top (keeps UX sane)
|
||||||
proposedRow = min(proposedRow, flatItems.count - 1)
|
if proposedRow <= 0 { proposedRow = 1 }
|
||||||
|
|
||||||
// Check for zone transition and trigger haptic feedback
|
proposedRow = min(max(0, proposedRow), max(0, flatItems.count - 1))
|
||||||
|
|
||||||
|
// Haptics / visuals
|
||||||
checkZoneTransition(at: proposedRow)
|
checkZoneTransition(at: proposedRow)
|
||||||
|
|
||||||
switch item {
|
// If already valid, allow it.
|
||||||
case .travel, .customItem:
|
if validDestinationRowsProposed.contains(proposedRow) {
|
||||||
// UNIFIED CONSTRAINT LOGIC using pre-calculated validDropRows
|
return IndexPath(row: proposedRow, section: 0)
|
||||||
// This eliminates bouncing by using a simple lookup instead of recalculating
|
}
|
||||||
return snapToValidRow(proposedRow)
|
|
||||||
|
|
||||||
default:
|
// Snap to nearest valid destination (proposed coordinate space)
|
||||||
// Fixed items (shouldn't reach here since canMoveRowAt returns false)
|
guard let snapped = nearestValue(in: validDestinationRowsProposed, to: proposedRow) else {
|
||||||
return sourceIndexPath
|
return sourceIndexPath
|
||||||
}
|
}
|
||||||
|
return IndexPath(row: snapped, section: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Snaps a proposed row to the nearest valid drop position.
|
// MARK: - Drag Destination Precomputation (semantic day + sortOrder)
|
||||||
///
|
|
||||||
/// Uses pre-calculated `validDropRows` for O(log n) lookup.
|
|
||||||
/// If the proposed row is already valid, returns it immediately (prevents bouncing).
|
|
||||||
/// Otherwise, finds the nearest valid row using binary search.
|
|
||||||
private func snapToValidRow(_ proposedRow: Int) -> 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
|
/// Nearest value in a sorted Int array to the target (binary search).
|
||||||
guard !validDropRows.isEmpty else {
|
private func nearestValue(in sorted: [Int], to target: Int) -> Int? {
|
||||||
return IndexPath(row: proposedRow, section: 0)
|
guard !sorted.isEmpty else { return nil }
|
||||||
}
|
|
||||||
|
|
||||||
// Binary search for insertion point
|
|
||||||
var low = 0
|
var low = 0
|
||||||
var high = validDropRows.count
|
var high = sorted.count
|
||||||
|
|
||||||
while low < high {
|
while low < high {
|
||||||
let mid = (low + high) / 2
|
let mid = (low + high) / 2
|
||||||
if validDropRows[mid] < proposedRow {
|
if sorted[mid] < target { low = mid + 1 } else { high = mid }
|
||||||
low = mid + 1
|
}
|
||||||
|
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).
|
||||||
|
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 {
|
} else {
|
||||||
high = mid
|
if v >= 0 { return v }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
i -= 1
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// low is now the insertion point - check neighbors to find nearest
|
func scanForward(from start: Int) -> Double? {
|
||||||
let before = low > 0 ? validDropRows[low - 1] : nil
|
var i = start
|
||||||
let after = low < validDropRows.count ? validDropRows[low] : nil
|
while i < items.count {
|
||||||
|
if case .dayHeader(let d, _) = items[i], d != day { break }
|
||||||
let nearest: Int
|
if case .dayHeader = items[i] { break }
|
||||||
if let b = before, let a = after {
|
if case .games(_, let d) = items[i], d == day { break }
|
||||||
// Both neighbors exist - pick the closer one
|
if let v = movableSortOrder(i) {
|
||||||
nearest = (proposedRow - b) <= (a - proposedRow) ? b : a
|
if isBeforeGames {
|
||||||
} else if let b = before {
|
if v < 0 { return v }
|
||||||
nearest = b
|
|
||||||
} else if let a = after {
|
|
||||||
nearest = a
|
|
||||||
} else {
|
} else {
|
||||||
nearest = proposedRow // Fallback (shouldn't happen)
|
if v >= 0 { return v }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return IndexPath(row: nearest, section: 0)
|
if isBeforeGames {
|
||||||
}
|
let prev = scanBackward(from: row - 1)
|
||||||
|
let next = scanForward(from: row)
|
||||||
|
|
||||||
/// Calculates which day a travel segment would belong to if dropped at a proposed position.
|
let upperBound: Double = 0.0
|
||||||
///
|
switch (prev, next) {
|
||||||
/// Similar to `dayForTravelAt`, but used during the drag (before the move completes).
|
case (nil, nil):
|
||||||
/// Must exclude the item being dragged from the scan, since it will be removed
|
return -1.0
|
||||||
/// from its current position.
|
case (let p?, nil):
|
||||||
///
|
return (p + upperBound) / 2.0
|
||||||
/// - Parameters:
|
case (nil, let n?):
|
||||||
/// - row: The proposed drop position
|
return n / 2.0
|
||||||
/// - excluding: The source row to skip (the item being dragged)
|
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 {
|
private func dayForTravelAtProposed(row: Int, excluding: Int) -> Int {
|
||||||
// Scan forward, skipping the item being moved
|
// Scan forward, skipping the item being moved
|
||||||
for i in row..<flatItems.count {
|
for i in row..<flatItems.count {
|
||||||
@@ -1152,57 +1321,95 @@ final class ItineraryTableViewController: UITableViewController {
|
|||||||
/// **Scanning logic:** We scan backwards and forwards from the drop position
|
/// **Scanning logic:** We scan backwards and forwards from the drop position
|
||||||
/// to find adjacent custom items, stopping at day boundaries (headers, travel).
|
/// to find adjacent custom items, stopping at day boundaries (headers, travel).
|
||||||
private func calculateSortOrder(at row: Int) -> Double {
|
private func calculateSortOrder(at row: Int) -> Double {
|
||||||
var prevSortOrder: Double?
|
let day = dayNumber(forRow: row)
|
||||||
var nextSortOrder: Double?
|
|
||||||
|
|
||||||
// SCAN BACKWARDS to find previous custom item in this day
|
// Find games row for this day (if any)
|
||||||
for i in stride(from: row - 1, through: 0, by: -1) {
|
var gamesRow: Int? = nil
|
||||||
switch flatItems[i] {
|
for i in 0..<flatItems.count {
|
||||||
case .customItem(let item):
|
if case .games(_, let d) = flatItems[i], d == day {
|
||||||
// Found a custom item - use its sortOrder
|
gamesRow = i
|
||||||
prevSortOrder = item.sortOrder
|
|
||||||
case .dayHeader, .travel:
|
|
||||||
// Hit a day boundary - no previous custom item in this day
|
|
||||||
break
|
break
|
||||||
case .games:
|
|
||||||
// Skip non-custom, non-boundary items
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
// Stop scanning once we found an item or hit a boundary
|
if case .dayHeader(let d, _) = flatItems[i], d > day {
|
||||||
if prevSortOrder != nil { break }
|
|
||||||
if case .dayHeader = flatItems[i] { break }
|
|
||||||
if case .travel = flatItems[i] { break }
|
|
||||||
}
|
|
||||||
|
|
||||||
// SCAN FORWARDS to find next custom item in this day
|
|
||||||
for i in row..<flatItems.count {
|
|
||||||
switch flatItems[i] {
|
|
||||||
case .customItem(let item):
|
|
||||||
nextSortOrder = item.sortOrder
|
|
||||||
case .dayHeader, .travel:
|
|
||||||
break
|
break
|
||||||
case .games:
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
if nextSortOrder != nil { break }
|
|
||||||
if case .dayHeader = flatItems[i] { break }
|
|
||||||
if case .travel = flatItems[i] { break }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// CALCULATE sortOrder based on what we found
|
let isBeforeGames = (gamesRow != nil && row <= gamesRow!)
|
||||||
switch (prevSortOrder, nextSortOrder) {
|
|
||||||
|
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):
|
case (nil, nil):
|
||||||
// No adjacent items - first item in this day
|
return -1.0
|
||||||
return 1.0
|
case (let p?, nil):
|
||||||
case (let prev?, nil):
|
return (p + upperBound) / 2.0
|
||||||
// After the last item - add 1.0 to create spacing
|
case (nil, let n?):
|
||||||
return prev + 1.0
|
return n / 2.0
|
||||||
case (nil, let next?):
|
case (let p?, let n?):
|
||||||
// Before the first item - halve to stay positive
|
return (p + n) / 2.0
|
||||||
return next / 2.0
|
}
|
||||||
case (let prev?, let next?):
|
} else {
|
||||||
// Between two items - use exact midpoint
|
let prev = scanBackward(from: row - 1) ?? 0.0
|
||||||
return (prev + next) / 2.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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
let trip: Trip
|
let trip: Trip
|
||||||
let games: [RichGame]
|
let games: [RichGame]
|
||||||
let itineraryItems: [ItineraryItem]
|
let itineraryItems: [ItineraryItem]
|
||||||
let travelDayOverrides: [String: Int]
|
let travelOverrides: [String: TravelOverride]
|
||||||
let headerContent: HeaderContent
|
let headerContent: HeaderContent
|
||||||
|
|
||||||
// Callbacks
|
// Callbacks
|
||||||
var onTravelMoved: ((String, Int) -> Void)?
|
var onTravelMoved: ((String, Int, Double) -> Void)?
|
||||||
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
||||||
var onCustomItemTapped: ((ItineraryItem) -> Void)?
|
var onCustomItemTapped: ((ItineraryItem) -> Void)?
|
||||||
var onCustomItemDeleted: ((ItineraryItem) -> Void)?
|
var onCustomItemDeleted: ((ItineraryItem) -> Void)?
|
||||||
@@ -27,9 +27,9 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
trip: Trip,
|
trip: Trip,
|
||||||
games: [RichGame],
|
games: [RichGame],
|
||||||
itineraryItems: [ItineraryItem],
|
itineraryItems: [ItineraryItem],
|
||||||
travelDayOverrides: [String: Int],
|
travelOverrides: [String: TravelOverride],
|
||||||
@ViewBuilder headerContent: () -> HeaderContent,
|
@ViewBuilder headerContent: () -> HeaderContent,
|
||||||
onTravelMoved: ((String, Int) -> Void)? = nil,
|
onTravelMoved: ((String, Int, Double) -> Void)? = nil,
|
||||||
onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil,
|
onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil,
|
||||||
onCustomItemTapped: ((ItineraryItem) -> Void)? = nil,
|
onCustomItemTapped: ((ItineraryItem) -> Void)? = nil,
|
||||||
onCustomItemDeleted: ((ItineraryItem) -> Void)? = nil,
|
onCustomItemDeleted: ((ItineraryItem) -> Void)? = nil,
|
||||||
@@ -38,7 +38,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
self.trip = trip
|
self.trip = trip
|
||||||
self.games = games
|
self.games = games
|
||||||
self.itineraryItems = itineraryItems
|
self.itineraryItems = itineraryItems
|
||||||
self.travelDayOverrides = travelDayOverrides
|
self.travelOverrides = travelOverrides
|
||||||
self.headerContent = headerContent()
|
self.headerContent = headerContent()
|
||||||
self.onTravelMoved = onTravelMoved
|
self.onTravelMoved = onTravelMoved
|
||||||
self.onCustomItemMoved = onCustomItemMoved
|
self.onCustomItemMoved = onCustomItemMoved
|
||||||
@@ -82,8 +82,8 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
controller.setTableHeader(hostingController.view)
|
controller.setTableHeader(hostingController.view)
|
||||||
|
|
||||||
// Load initial data
|
// Load initial data
|
||||||
let (days, validRanges) = buildItineraryData()
|
let (days, validRanges, allItemsForConstraints) = buildItineraryData()
|
||||||
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: itineraryItems)
|
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
|
||||||
|
|
||||||
return controller
|
return controller
|
||||||
}
|
}
|
||||||
@@ -100,73 +100,142 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
// This avoids recreating the view hierarchy and prevents infinite loops
|
// This avoids recreating the view hierarchy and prevents infinite loops
|
||||||
context.coordinator.headerHostingController?.rootView = headerContent
|
context.coordinator.headerHostingController?.rootView = headerContent
|
||||||
|
|
||||||
let (days, validRanges) = buildItineraryData()
|
let (days, validRanges, allItemsForConstraints) = buildItineraryData()
|
||||||
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: itineraryItems)
|
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Build Itinerary Data
|
// MARK: - Build Itinerary Data
|
||||||
|
|
||||||
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>]) {
|
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem]) {
|
||||||
let tripDays = calculateTripDays()
|
let tripDays = calculateTripDays()
|
||||||
var travelValidRanges: [String: ClosedRange<Int>] = [:]
|
var travelValidRanges: [String: ClosedRange<Int>] = [:]
|
||||||
|
|
||||||
// Pre-calculate travel segment placements
|
// Build travel as semantic items with (day, sortOrder)
|
||||||
var travelByDay: [Int: TravelSegment] = [:]
|
var travelItems: [ItineraryItem] = []
|
||||||
|
travelItems.reserveCapacity(trip.travelSegments.count)
|
||||||
|
|
||||||
|
func cityFromGameId(_ gameId: String) -> String? {
|
||||||
|
let comps = gameId.components(separatedBy: "-")
|
||||||
|
guard comps.count >= 2 else { return nil }
|
||||||
|
return comps[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
func gamesIn(city: String, day: Int) -> [ItineraryItem] {
|
||||||
|
itineraryItems.filter { item in
|
||||||
|
guard item.day == day else { return false }
|
||||||
|
guard case .game(let gid) = item.kind else { return false }
|
||||||
|
guard let c = cityFromGameId(gid) else { return false }
|
||||||
|
return cityMatches(c, searchCity: city)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for segment in trip.travelSegments {
|
for segment in trip.travelSegments {
|
||||||
let travelId = stableTravelAnchorId(segment)
|
let travelId = stableTravelAnchorId(segment)
|
||||||
let fromCity = segment.fromLocation.name
|
let fromCity = segment.fromLocation.name
|
||||||
let toCity = segment.toLocation.name
|
let toCity = segment.toLocation.name
|
||||||
|
|
||||||
// Calculate valid range
|
// VALID RANGE:
|
||||||
// Travel "on day N" appears BEFORE day N's header
|
// - Earliest: day of last from-city game (travel can happen AFTER that game)
|
||||||
// So minDay must be AFTER the last game day in departure city
|
// - Latest: day of first to-city game (travel can happen BEFORE that game)
|
||||||
let lastGameInFromCity = findLastGameDay(in: fromCity, tripDays: tripDays)
|
let lastFromGameDay = findLastGameDay(in: fromCity, tripDays: tripDays)
|
||||||
let firstGameInToCity = findFirstGameDay(in: toCity, tripDays: tripDays)
|
let firstToGameDay = findFirstGameDay(in: toCity, tripDays: tripDays)
|
||||||
let minDay = max(lastGameInFromCity + 1, 1) // Day AFTER last game in from city
|
|
||||||
let maxDay = min(firstGameInToCity, tripDays.count) // Can arrive same day as first game
|
|
||||||
let validRange = minDay <= maxDay ? minDay...maxDay : maxDay...maxDay
|
|
||||||
|
|
||||||
|
let minDay = max(lastFromGameDay == 0 ? 1 : lastFromGameDay, 1)
|
||||||
|
let maxDay = min(firstToGameDay == 0 ? tripDays.count : firstToGameDay, tripDays.count)
|
||||||
|
|
||||||
|
let validRange = (minDay <= maxDay) ? (minDay...maxDay) : (maxDay...maxDay)
|
||||||
travelValidRanges[travelId] = validRange
|
travelValidRanges[travelId] = validRange
|
||||||
|
|
||||||
// Calculate default day
|
// Placement (override if valid)
|
||||||
let defaultDay: Int
|
let placement: TravelOverride
|
||||||
if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count {
|
if let override = travelOverrides[travelId], validRange.contains(override.day) {
|
||||||
defaultDay = lastGameInFromCity + 1
|
placement = override
|
||||||
} else if lastGameInFromCity > 0 {
|
|
||||||
defaultDay = lastGameInFromCity
|
|
||||||
} else {
|
} else {
|
||||||
defaultDay = 1
|
// Default day: minDay. Default sortOrder depends on whether it's an edge game day.
|
||||||
|
let day = minDay
|
||||||
|
|
||||||
|
// If we're on the last-from-game day, default to AFTER those games.
|
||||||
|
let fromGames = gamesIn(city: fromCity, day: day)
|
||||||
|
let maxFrom = fromGames.map { $0.sortOrder }.max() ?? 0.0
|
||||||
|
var sortOrder = maxFrom + 1.0
|
||||||
|
|
||||||
|
// If we're on the first-to-game day (and it's the same chosen day), default to BEFORE those games.
|
||||||
|
let toGames = gamesIn(city: toCity, day: day)
|
||||||
|
if !toGames.isEmpty {
|
||||||
|
let minTo = toGames.map { $0.sortOrder }.min() ?? 0.0
|
||||||
|
sortOrder = minTo - 1.0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use override if valid, otherwise use default
|
placement = TravelOverride(day: day, sortOrder: sortOrder)
|
||||||
if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) {
|
|
||||||
travelByDay[overrideDay] = segment
|
|
||||||
} else {
|
|
||||||
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
|
|
||||||
travelByDay[clampedDefault] = segment
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let travelItem = ItineraryItem(
|
||||||
|
tripId: trip.id,
|
||||||
|
day: placement.day,
|
||||||
|
sortOrder: placement.sortOrder,
|
||||||
|
kind: .travel(
|
||||||
|
TravelInfo(
|
||||||
|
fromCity: fromCity,
|
||||||
|
toCity: toCity,
|
||||||
|
distanceMeters: segment.distanceMeters,
|
||||||
|
durationSeconds: segment.durationSeconds
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
travelItems.append(travelItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build day data
|
// Build day data
|
||||||
var days: [ItineraryDayData] = []
|
var days: [ItineraryDayData] = []
|
||||||
|
days.reserveCapacity(tripDays.count)
|
||||||
|
|
||||||
for (index, dayDate) in tripDays.enumerated() {
|
for (index, dayDate) in tripDays.enumerated() {
|
||||||
let dayNum = index + 1
|
let dayNum = index + 1
|
||||||
let gamesOnDay = gamesOn(date: dayDate)
|
let gamesOnDay = gamesOn(date: dayDate)
|
||||||
var items: [ItineraryRowItem] = []
|
|
||||||
|
|
||||||
// Travel before this day (travel is stored on the destination day)
|
var rows: [ItineraryRowItem] = []
|
||||||
let travelBefore: TravelSegment? = travelByDay[dayNum]
|
|
||||||
|
|
||||||
// Custom items for this day - filter by day and custom kind, sort by sortOrder
|
// Custom items for this day
|
||||||
// Note: Add button is now embedded in the day header row (not a separate item)
|
|
||||||
let customItemsForDay = itineraryItems
|
let customItemsForDay = itineraryItems
|
||||||
.filter { $0.day == dayNum && $0.isCustom }
|
.filter { $0.day == dayNum && $0.isCustom }
|
||||||
.sorted { $0.sortOrder < $1.sortOrder }
|
.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
|
|
||||||
for item in customItemsForDay {
|
for item in customItemsForDay {
|
||||||
items.append(ItineraryRowItem.customItem(item))
|
rows.append(.customItem(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Travel items for this day (as rows). Ordering comes from sortOrder via controller lookup.
|
||||||
|
let travelsForDay = travelItems
|
||||||
|
.filter { $0.day == dayNum }
|
||||||
|
.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
|
for travel in travelsForDay {
|
||||||
|
// Find the segment matching this travel
|
||||||
|
if let info = travel.travelInfo,
|
||||||
|
let seg = trip.travelSegments.first(where: {
|
||||||
|
$0.fromLocation.name.lowercased() == info.fromCity.lowercased()
|
||||||
|
&& $0.toLocation.name.lowercased() == info.toCity.lowercased()
|
||||||
|
}) {
|
||||||
|
rows.append(.travel(seg, dayNumber: dayNum))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort rows by semantic sortOrder (custom uses its own; travel via travelItems)
|
||||||
|
rows.sort { a, b in
|
||||||
|
func so(_ r: ItineraryRowItem) -> Double {
|
||||||
|
switch r {
|
||||||
|
case .customItem(let it): return it.sortOrder
|
||||||
|
case .travel(let seg, _):
|
||||||
|
let id = stableTravelAnchorId(seg)
|
||||||
|
return (travelOverrides[id]?.sortOrder)
|
||||||
|
?? (travelItems.first(where: { ti in
|
||||||
|
guard case .travel(let inf) = ti.kind else { return false }
|
||||||
|
return inf.fromCity.lowercased() == seg.fromLocation.name.lowercased()
|
||||||
|
&& inf.toCity.lowercased() == seg.toLocation.name.lowercased()
|
||||||
|
})?.sortOrder ?? 0.0)
|
||||||
|
default:
|
||||||
|
return 0.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return so(a) < so(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
let dayData = ItineraryDayData(
|
let dayData = ItineraryDayData(
|
||||||
@@ -174,13 +243,13 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
|||||||
dayNumber: dayNum,
|
dayNumber: dayNum,
|
||||||
date: dayDate,
|
date: dayDate,
|
||||||
games: gamesOnDay,
|
games: gamesOnDay,
|
||||||
items: items,
|
items: rows,
|
||||||
travelBefore: travelBefore
|
travelBefore: nil
|
||||||
)
|
)
|
||||||
days.append(dayData)
|
days.append(dayData)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (days, travelValidRanges)
|
return (days, travelValidRanges, itineraryItems + travelItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Helper Methods
|
// MARK: - Helper Methods
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ struct TripDetailView: View {
|
|||||||
@State private var draggedItem: ItineraryItem?
|
@State private var draggedItem: ItineraryItem?
|
||||||
@State private var draggedTravelId: String? // Track which travel segment is being dragged
|
@State private var draggedTravelId: String? // Track which travel segment is being dragged
|
||||||
@State private var dropTargetId: String? // Track which drop zone is being hovered
|
@State private var dropTargetId: String? // Track which drop zone is being hovered
|
||||||
@State private var travelDayOverrides: [String: Int] = [:] // Key: travel ID, Value: day number
|
@State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder
|
||||||
|
|
||||||
private let exportService = ExportService()
|
private let exportService = ExportService()
|
||||||
private let dataProvider = AppDataProvider.shared
|
private let dataProvider = AppDataProvider.shared
|
||||||
@@ -81,19 +81,54 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
bodyContent
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var bodyContent: some View {
|
||||||
mainContent
|
mainContent
|
||||||
.background(Theme.backgroundGradient(colorScheme))
|
.background(Theme.backgroundGradient(colorScheme))
|
||||||
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
||||||
.toolbar {
|
.toolbar { toolbarContent }
|
||||||
|
.modifier(SheetModifiers(
|
||||||
|
showExportSheet: $showExportSheet,
|
||||||
|
exportURL: exportURL,
|
||||||
|
showProPaywall: $showProPaywall,
|
||||||
|
addItemAnchor: $addItemAnchor,
|
||||||
|
editingItem: $editingItem,
|
||||||
|
tripId: trip.id,
|
||||||
|
saveItineraryItem: saveItineraryItem
|
||||||
|
))
|
||||||
|
.onAppear { checkIfSaved() }
|
||||||
|
.task {
|
||||||
|
await loadGamesIfNeeded()
|
||||||
|
if allowCustomItems {
|
||||||
|
await loadItineraryItems()
|
||||||
|
await setupSubscription()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onDisappear { subscriptionCancellable?.cancel() }
|
||||||
|
.onChange(of: itineraryItems) { _, newItems in
|
||||||
|
handleItineraryItemsChange(newItems)
|
||||||
|
}
|
||||||
|
.onChange(of: travelOverrides.count) { _, _ in
|
||||||
|
draggedTravelId = nil
|
||||||
|
dropTargetId = nil
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
if isExporting { exportProgressOverlay }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ToolbarContentBuilder
|
||||||
|
private var toolbarContent: some ToolbarContent {
|
||||||
ToolbarItemGroup(placement: .primaryAction) {
|
ToolbarItemGroup(placement: .primaryAction) {
|
||||||
ShareButton(trip: trip, style: .icon)
|
ShareButton(trip: trip, style: .icon)
|
||||||
.foregroundStyle(Theme.warmOrange)
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
if StoreManager.shared.isPro {
|
if StoreManager.shared.isPro {
|
||||||
Task {
|
Task { await exportPDF() }
|
||||||
await exportPDF()
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
showProPaywall = true
|
showProPaywall = true
|
||||||
}
|
}
|
||||||
@@ -108,50 +143,10 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showExportSheet) {
|
|
||||||
if let url = exportURL {
|
private func handleItineraryItemsChange(_ newItems: [ItineraryItem]) {
|
||||||
ShareSheet(items: [url])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(isPresented: $showProPaywall) {
|
|
||||||
PaywallView()
|
|
||||||
}
|
|
||||||
.sheet(item: $addItemAnchor) { anchor in
|
|
||||||
AddItemSheet(
|
|
||||||
tripId: trip.id,
|
|
||||||
day: anchor.day,
|
|
||||||
existingItem: nil
|
|
||||||
) { item in
|
|
||||||
Task { await saveItineraryItem(item) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.sheet(item: $editingItem) { item in
|
|
||||||
AddItemSheet(
|
|
||||||
tripId: trip.id,
|
|
||||||
day: item.day,
|
|
||||||
existingItem: item
|
|
||||||
) { updatedItem in
|
|
||||||
Task { await saveItineraryItem(updatedItem) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
checkIfSaved()
|
|
||||||
}
|
|
||||||
.task {
|
|
||||||
await loadGamesIfNeeded()
|
|
||||||
if allowCustomItems {
|
|
||||||
await loadItineraryItems()
|
|
||||||
await setupSubscription()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onDisappear {
|
|
||||||
subscriptionCancellable?.cancel()
|
|
||||||
}
|
|
||||||
.onChange(of: itineraryItems) { _, newItems in
|
|
||||||
// Clear drag state after items update (move completed)
|
|
||||||
draggedItem = nil
|
draggedItem = nil
|
||||||
dropTargetId = nil
|
dropTargetId = nil
|
||||||
// Recalculate routes when custom items change (mappable items affect route)
|
|
||||||
print("🗺️ [MapUpdate] itineraryItems changed, count: \(newItems.count)")
|
print("🗺️ [MapUpdate] itineraryItems changed, count: \(newItems.count)")
|
||||||
for item in newItems {
|
for item in newItems {
|
||||||
if item.isCustom, let info = item.customInfo, info.isMappable {
|
if item.isCustom, let info = item.customInfo, info.isMappable {
|
||||||
@@ -163,17 +158,6 @@ struct TripDetailView: View {
|
|||||||
await fetchDrivingRoutes()
|
await fetchDrivingRoutes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: travelDayOverrides) { _, _ in
|
|
||||||
// Clear drag state after travel move completed
|
|
||||||
draggedTravelId = nil
|
|
||||||
dropTargetId = nil
|
|
||||||
}
|
|
||||||
.overlay {
|
|
||||||
if isExporting {
|
|
||||||
exportProgressOverlay
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Main Content
|
// MARK: - Main Content
|
||||||
|
|
||||||
@@ -185,7 +169,7 @@ struct TripDetailView: View {
|
|||||||
trip: trip,
|
trip: trip,
|
||||||
games: Array(games.values),
|
games: Array(games.values),
|
||||||
itineraryItems: itineraryItems,
|
itineraryItems: itineraryItems,
|
||||||
travelDayOverrides: travelDayOverrides,
|
travelOverrides: travelOverrides,
|
||||||
headerContent: {
|
headerContent: {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
// Hero Map
|
// Hero Map
|
||||||
@@ -214,10 +198,10 @@ struct TripDetailView: View {
|
|||||||
.padding(.bottom, Theme.Spacing.md)
|
.padding(.bottom, Theme.Spacing.md)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onTravelMoved: { travelId, newDay in
|
onTravelMoved: { travelId, newDay, newSortOrder in
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
withAnimation {
|
withAnimation {
|
||||||
travelDayOverrides[travelId] = newDay
|
travelOverrides[travelId] = TravelOverride(day: newDay, sortOrder: newSortOrder)
|
||||||
}
|
}
|
||||||
await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay)
|
await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay)
|
||||||
}
|
}
|
||||||
@@ -818,8 +802,9 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for user override - only use if within valid range
|
// Check for user override - only use if within valid range
|
||||||
if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) {
|
if let override = travelOverrides[travelId],
|
||||||
travelByDay[overrideDay] = segment
|
validRange.contains(override.day) {
|
||||||
|
travelByDay[override.day] = segment
|
||||||
} else {
|
} else {
|
||||||
// Use default (clamped to valid range)
|
// Use default (clamped to valid range)
|
||||||
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
|
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
|
||||||
@@ -1276,16 +1261,18 @@ struct TripDetailView: View {
|
|||||||
print("✅ [ItineraryItems] Loaded \(items.count) items from CloudKit")
|
print("✅ [ItineraryItems] Loaded \(items.count) items from CloudKit")
|
||||||
itineraryItems = items
|
itineraryItems = items
|
||||||
|
|
||||||
// Extract travel day overrides from travel-type items
|
// Extract travel overrides (day + sortOrder) from travel-type items
|
||||||
var overrides: [String: Int] = [:]
|
var overrides: [String: TravelOverride] = [:]
|
||||||
|
|
||||||
for item in items where item.isTravel {
|
for item in items where item.isTravel {
|
||||||
if let travelInfo = item.travelInfo {
|
guard let travelInfo = item.travelInfo else { continue }
|
||||||
let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())"
|
let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())"
|
||||||
overrides[travelId] = item.day
|
|
||||||
|
overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
travelDayOverrides = overrides
|
travelOverrides = overrides
|
||||||
print("✅ [TravelOverrides] Extracted \(overrides.count) travel day overrides")
|
print("✅ [TravelOverrides] Extracted \(overrides.count) travel overrides (day + sortOrder)")
|
||||||
} catch {
|
} catch {
|
||||||
print("❌ [ItineraryItems] Failed to load: \(error)")
|
print("❌ [ItineraryItems] Failed to load: \(error)")
|
||||||
}
|
}
|
||||||
@@ -1357,21 +1344,35 @@ struct TripDetailView: View {
|
|||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
// Check if this is a travel segment being dropped
|
// Check if this is a travel segment being dropped
|
||||||
if droppedId.hasPrefix("travel:") {
|
if droppedId.hasPrefix("travel:") {
|
||||||
// Validate travel is within valid bounds
|
// Validate travel is within valid bounds (day-level)
|
||||||
if let validRange = self.validDayRange(for: droppedId) {
|
if let validRange = self.validDayRange(for: droppedId) {
|
||||||
guard validRange.contains(dayNumber) else {
|
guard validRange.contains(dayNumber) else {
|
||||||
// Day is outside valid range - reject drop (state already cleared)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move travel to this day
|
// Choose a semantic sortOrder for dropping onto a day:
|
||||||
|
// - If this day has games, default to AFTER games (positive)
|
||||||
|
// - If no games, default to 1.0
|
||||||
|
//
|
||||||
|
// You can later support "before games" drops by using a negative sortOrder
|
||||||
|
// when the user drops above the games row.
|
||||||
|
let maxSortOrderOnDay = self.itineraryItems
|
||||||
|
.filter { $0.day == dayNumber }
|
||||||
|
.map { $0.sortOrder }
|
||||||
|
.max() ?? 0.0
|
||||||
|
|
||||||
|
let newSortOrder = max(maxSortOrderOnDay + 1.0, 1.0)
|
||||||
|
|
||||||
withAnimation {
|
withAnimation {
|
||||||
self.travelDayOverrides[droppedId] = dayNumber
|
self.travelOverrides[droppedId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the override to CloudKit
|
// Persist to CloudKit as a travel ItineraryItem
|
||||||
await self.saveTravelDayOverride(travelAnchorId: droppedId, displayDay: dayNumber)
|
await self.saveTravelDayOverride(
|
||||||
|
travelAnchorId: droppedId,
|
||||||
|
displayDay: dayNumber
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1901,3 +1902,52 @@ struct TripMapView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Travel Override
|
||||||
|
|
||||||
|
struct TravelOverride: Equatable {
|
||||||
|
let day: Int
|
||||||
|
let sortOrder: Double
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Sheet Modifiers
|
||||||
|
|
||||||
|
private struct SheetModifiers: ViewModifier {
|
||||||
|
@Binding var showExportSheet: Bool
|
||||||
|
let exportURL: URL?
|
||||||
|
@Binding var showProPaywall: Bool
|
||||||
|
@Binding var addItemAnchor: AddItemAnchor?
|
||||||
|
@Binding var editingItem: ItineraryItem?
|
||||||
|
let tripId: UUID
|
||||||
|
let saveItineraryItem: (ItineraryItem) async -> Void
|
||||||
|
|
||||||
|
func body(content: Content) -> some View {
|
||||||
|
content
|
||||||
|
.sheet(isPresented: $showExportSheet) {
|
||||||
|
if let url = exportURL {
|
||||||
|
ShareSheet(items: [url])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showProPaywall) {
|
||||||
|
PaywallView()
|
||||||
|
}
|
||||||
|
.sheet(item: $addItemAnchor) { anchor in
|
||||||
|
AddItemSheet(
|
||||||
|
tripId: tripId,
|
||||||
|
day: anchor.day,
|
||||||
|
existingItem: nil
|
||||||
|
) { item in
|
||||||
|
Task { await saveItineraryItem(item) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.sheet(item: $editingItem) { item in
|
||||||
|
AddItemSheet(
|
||||||
|
tripId: tripId,
|
||||||
|
day: item.day,
|
||||||
|
existingItem: item
|
||||||
|
) { updatedItem in
|
||||||
|
Task { await saveItineraryItem(updatedItem) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user