refactor(itinerary): replace anchor-based positioning with day/sortOrder
Replace complex anchor system (anchorType, anchorId, anchorDay) with simple (day: Int, sortOrder: Double) positioning for custom items. Changes: - CustomItineraryItem: Remove anchor fields, add day and sortOrder - CKModels: Add migration fallback from old CloudKit fields - ItineraryTableViewController: Add calculateSortOrder() for midpoint insertion - TripDetailView: Simplify callbacks, itinerarySections, and routeWaypoints - AddItemSheet: Take simple day parameter instead of anchor - SavedTrip: Update LocalCustomItem SwiftData model Benefits: - Items freely movable via drag-and-drop - Route waypoints follow exact visual order - Simpler mental model: position = (day, sortOrder) - Midpoint insertion allows unlimited reordering Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -27,7 +27,7 @@ enum ItineraryRowItem: Identifiable, Equatable {
|
||||
case dayHeader(dayNumber: Int, date: Date, games: [RichGame])
|
||||
case travel(TravelSegment, dayNumber: Int) // dayNumber = the day this travel is associated with
|
||||
case customItem(CustomItineraryItem)
|
||||
case addButton(day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?)
|
||||
case addButton(day: Int) // Simplified - just needs day
|
||||
|
||||
var id: String {
|
||||
switch self {
|
||||
@@ -37,8 +37,8 @@ enum ItineraryRowItem: Identifiable, Equatable {
|
||||
return "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
case .customItem(let item):
|
||||
return "item:\(item.id.uuidString)"
|
||||
case .addButton(let day, let anchorType, let anchorId):
|
||||
return "add:\(day)-\(anchorType.rawValue)-\(anchorId ?? "nil")"
|
||||
case .addButton(let day):
|
||||
return "add:\(day)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,10 +68,10 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
|
||||
// Callbacks
|
||||
var onTravelMoved: ((String, Int) -> Void)? // travelId, newDay
|
||||
var onCustomItemMoved: ((UUID, Int, CustomItineraryItem.AnchorType, String?) -> Void)?
|
||||
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
||||
var onCustomItemTapped: ((CustomItineraryItem) -> Void)?
|
||||
var onCustomItemDeleted: ((CustomItineraryItem) -> Void)?
|
||||
var onAddButtonTapped: ((Int, CustomItineraryItem.AnchorType, String?) -> Void)?
|
||||
var onAddButtonTapped: ((Int) -> Void)? // Just day number
|
||||
|
||||
// Cell reuse identifiers
|
||||
private let dayHeaderCellId = "DayHeaderCell"
|
||||
@@ -234,9 +234,9 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
configureCustomItemCell(cell, item: customItem)
|
||||
return cell
|
||||
|
||||
case .addButton(let day, let anchorType, let anchorId):
|
||||
case .addButton(let day):
|
||||
let cell = tableView.dequeueReusableCell(withIdentifier: addButtonCellId, for: indexPath)
|
||||
configureAddButtonCell(cell, day: day, anchorType: anchorType, anchorId: anchorId)
|
||||
configureAddButtonCell(cell, day: day)
|
||||
return cell
|
||||
}
|
||||
}
|
||||
@@ -266,8 +266,8 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
|
||||
case .customItem(let customItem):
|
||||
let destinationDay = dayNumber(forRow: destinationIndexPath.row)
|
||||
let (anchorType, anchorId) = determineAnchor(at: destinationIndexPath.row)
|
||||
onCustomItemMoved?(customItem.id, destinationDay, anchorType, anchorId)
|
||||
let sortOrder = calculateSortOrder(at: destinationIndexPath.row)
|
||||
onCustomItemMoved?(customItem.id, destinationDay, sortOrder)
|
||||
|
||||
default:
|
||||
break
|
||||
@@ -403,8 +403,8 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
case .customItem(let customItem):
|
||||
onCustomItemTapped?(customItem)
|
||||
|
||||
case .addButton(let day, let anchorType, let anchorId):
|
||||
onAddButtonTapped?(day, anchorType, anchorId)
|
||||
case .addButton(let day):
|
||||
onAddButtonTapped?(day)
|
||||
|
||||
default:
|
||||
break
|
||||
@@ -435,53 +435,64 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func determineAnchor(at row: Int) -> (CustomItineraryItem.AnchorType, String?) {
|
||||
// Scan backwards to find the day's context
|
||||
// Structure: travel (optional) -> dayHeader -> items
|
||||
var foundTravel: TravelSegment?
|
||||
var foundDayGames: [RichGame] = []
|
||||
/// Calculate the sortOrder for an item dropped at the given row position
|
||||
/// Uses midpoint insertion: if between sortOrder 1.0 and 2.0, returns 1.5
|
||||
private func calculateSortOrder(at row: Int) -> Double {
|
||||
// Find adjacent custom items to calculate midpoint
|
||||
var prevSortOrder: Double?
|
||||
var nextSortOrder: Double?
|
||||
|
||||
// Scan backwards for previous custom item in same day
|
||||
for i in stride(from: row - 1, through: 0, by: -1) {
|
||||
switch flatItems[i] {
|
||||
case .travel(let segment, _):
|
||||
// Found travel - if this is the first significant item, use afterTravel
|
||||
// But only if we haven't passed a day header yet
|
||||
if foundDayGames.isEmpty {
|
||||
foundTravel = segment
|
||||
}
|
||||
// Travel marks the boundary - stop scanning
|
||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
// If the drop is right after travel (no day header between), use afterTravel
|
||||
if foundDayGames.isEmpty {
|
||||
return (.afterTravel, travelId)
|
||||
}
|
||||
// Otherwise we already passed a day header, use that context
|
||||
case .customItem(let item):
|
||||
prevSortOrder = item.sortOrder
|
||||
break
|
||||
|
||||
case .dayHeader(_, _, let games):
|
||||
// Found the day header for this section
|
||||
foundDayGames = games
|
||||
// If day has games, items dropped after should be afterGame
|
||||
if let lastGame = games.last {
|
||||
return (.afterGame, lastGame.game.id)
|
||||
}
|
||||
// No games - check if there's travel before this day
|
||||
// Continue scanning to find travel
|
||||
continue
|
||||
|
||||
case .customItem, .addButton:
|
||||
// Skip these, keep scanning backwards
|
||||
case .dayHeader, .travel:
|
||||
// Hit a boundary - no previous item in this section
|
||||
break
|
||||
case .addButton:
|
||||
continue
|
||||
}
|
||||
if prevSortOrder != nil { break }
|
||||
// If we hit dayHeader or travel, stop scanning
|
||||
if case .dayHeader = flatItems[i] { break }
|
||||
if case .travel = flatItems[i] { break }
|
||||
}
|
||||
|
||||
// If we found travel but no games, use afterTravel
|
||||
if let segment = foundTravel {
|
||||
let travelId = "travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
return (.afterTravel, travelId)
|
||||
// Scan forwards for next custom item in same day
|
||||
for i in row..<flatItems.count {
|
||||
switch flatItems[i] {
|
||||
case .customItem(let item):
|
||||
nextSortOrder = item.sortOrder
|
||||
break
|
||||
case .dayHeader, .travel:
|
||||
// Hit a boundary - no next item in this section
|
||||
break
|
||||
case .addButton:
|
||||
continue
|
||||
}
|
||||
if nextSortOrder != nil { break }
|
||||
// If we hit dayHeader or travel, stop scanning
|
||||
if case .dayHeader = flatItems[i] { break }
|
||||
if case .travel = flatItems[i] { break }
|
||||
}
|
||||
|
||||
return (.startOfDay, nil)
|
||||
// Calculate appropriate sortOrder
|
||||
switch (prevSortOrder, nextSortOrder) {
|
||||
case (nil, nil):
|
||||
// No adjacent items - use 1.0
|
||||
return 1.0
|
||||
case (let prev?, nil):
|
||||
// After last item - use prev + 1.0
|
||||
return prev + 1.0
|
||||
case (nil, let next?):
|
||||
// Before first item - use next / 2.0
|
||||
return next / 2.0
|
||||
case (let prev?, let next?):
|
||||
// Between two items - use midpoint
|
||||
return (prev + next) / 2.0
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cell Configuration
|
||||
@@ -519,7 +530,7 @@ final class ItineraryTableViewController: UITableViewController {
|
||||
cell.selectionStyle = .default
|
||||
}
|
||||
|
||||
private func configureAddButtonCell(_ cell: UITableViewCell, day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) {
|
||||
private func configureAddButtonCell(_ cell: UITableViewCell, day: Int) {
|
||||
cell.contentConfiguration = UIHostingConfiguration {
|
||||
AddButtonRowView(colorScheme: colorScheme)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user