fix(itinerary): improve drag-drop reordering with stable anchors and visual feedback
- Add visual drop target indicator showing where items will land - Use stable travel anchor IDs (city names) instead of UUIDs that regenerate - Fix findDayForTravelSegment to look forward to arrival day, not backward - Add moveItemToBeginning() for inserting at position 0 when dropping on sections - Sort custom items by sortOrder in all filters - Sync shifted items to CloudKit after reorder - Add opaque backgrounds to CustomItemRow and TravelSection to hide timeline Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,8 +43,17 @@ struct CustomItemRow: View {
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, Theme.Spacing.sm)
|
||||
.background(Theme.warmOrange.opacity(0.08))
|
||||
.cornerRadius(8)
|
||||
.background {
|
||||
// Opaque base + semi-transparent accent
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Theme.cardBackground(colorScheme))
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Theme.warmOrange.opacity(0.1))
|
||||
}
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(Theme.warmOrange.opacity(0.3), lineWidth: 1)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contextMenu {
|
||||
|
||||
@@ -7,6 +7,7 @@ import SwiftUI
|
||||
import SwiftData
|
||||
import MapKit
|
||||
import Combine
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct TripDetailView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@@ -38,6 +39,7 @@ struct TripDetailView: View {
|
||||
@State private var editingItem: CustomItineraryItem?
|
||||
@State private var subscriptionCancellable: AnyCancellable?
|
||||
@State private var draggedItem: CustomItineraryItem?
|
||||
@State private var dropTargetId: String? // Track which drop zone is being hovered
|
||||
|
||||
private let exportService = ExportService()
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
@@ -172,6 +174,11 @@ struct TripDetailView: View {
|
||||
.onDisappear {
|
||||
subscriptionCancellable?.cancel()
|
||||
}
|
||||
.onChange(of: customItems) { _, _ in
|
||||
// Clear drag state after items update (move completed)
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
}
|
||||
.overlay {
|
||||
if isExporting {
|
||||
exportProgressOverlay
|
||||
@@ -407,6 +414,9 @@ struct TripDetailView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func itineraryRow(for section: ItinerarySection, at index: Int) -> some View {
|
||||
let sectionId = sectionIdentifier(for: section, at: index)
|
||||
let isDropTarget = dropTargetId == sectionId && draggedItem != nil
|
||||
|
||||
switch section {
|
||||
case .day(let dayNumber, let date, let gamesOnDay):
|
||||
DaySection(
|
||||
@@ -415,39 +425,68 @@ struct TripDetailView: View {
|
||||
games: gamesOnDay
|
||||
)
|
||||
.staggeredAnimation(index: index)
|
||||
.overlay(alignment: .bottom) {
|
||||
if isDropTarget {
|
||||
DropTargetIndicator()
|
||||
}
|
||||
}
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let itemIdString = items.first,
|
||||
let itemId = UUID(uuidString: itemIdString),
|
||||
let item = customItems.first(where: { $0.id == itemId }),
|
||||
let lastGame = gamesOnDay.last else { return false }
|
||||
Task {
|
||||
await moveItem(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id)
|
||||
// Insert at beginning (sortOrder 0) when dropping on day card
|
||||
await moveItemToBeginning(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id)
|
||||
}
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
|
||||
}
|
||||
}
|
||||
|
||||
case .travel(let segment):
|
||||
TravelSection(segment: segment)
|
||||
.staggeredAnimation(index: index)
|
||||
.overlay(alignment: .bottom) {
|
||||
if isDropTarget {
|
||||
DropTargetIndicator()
|
||||
}
|
||||
}
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let itemIdString = items.first,
|
||||
let itemId = UUID(uuidString: itemIdString),
|
||||
let item = customItems.first(where: { $0.id == itemId }) else { return false }
|
||||
// Find the day for this travel segment
|
||||
let day = findDayForTravelSegment(segment)
|
||||
// Use stable identifier instead of UUID (UUIDs change on reload)
|
||||
let stableAnchorId = stableTravelAnchorId(segment)
|
||||
Task {
|
||||
await moveItem(item, toDay: day, anchorType: .afterTravel, anchorId: segment.id.uuidString)
|
||||
// Insert at beginning when dropping on travel section
|
||||
await moveItemToBeginning(item, toDay: day, anchorType: .afterTravel, anchorId: stableAnchorId)
|
||||
}
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
|
||||
}
|
||||
}
|
||||
|
||||
case .customItem(let item):
|
||||
let isDragging = draggedItem?.id == item.id
|
||||
CustomItemRow(
|
||||
item: item,
|
||||
onTap: { editingItem = item },
|
||||
onDelete: { Task { await deleteCustomItem(item) } }
|
||||
)
|
||||
.opacity(isDragging ? 0.4 : 1.0)
|
||||
.staggeredAnimation(index: index)
|
||||
.overlay(alignment: .top) {
|
||||
if isDropTarget && !isDragging {
|
||||
DropTargetIndicator()
|
||||
}
|
||||
}
|
||||
.draggable(item.id.uuidString) {
|
||||
// Drag preview
|
||||
CustomItemRow(
|
||||
@@ -456,41 +495,75 @@ struct TripDetailView: View {
|
||||
onDelete: {}
|
||||
)
|
||||
.frame(width: 300)
|
||||
.opacity(0.8)
|
||||
.opacity(0.9)
|
||||
.onAppear { draggedItem = item }
|
||||
}
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let itemIdString = items.first,
|
||||
let itemId = UUID(uuidString: itemIdString),
|
||||
let draggedItem = customItems.first(where: { $0.id == itemId }),
|
||||
draggedItem.id != item.id else { return false }
|
||||
let droppedItem = customItems.first(where: { $0.id == itemId }),
|
||||
droppedItem.id != item.id else { return false }
|
||||
Task {
|
||||
await moveItem(draggedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item)
|
||||
await moveItem(droppedItem, toDay: item.anchorDay, anchorType: item.anchorType, anchorId: item.anchorId, beforeItem: item)
|
||||
}
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
|
||||
}
|
||||
}
|
||||
|
||||
case .addButton(let day, let anchorType, let anchorId):
|
||||
InlineAddButton {
|
||||
addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId)
|
||||
VStack(spacing: 0) {
|
||||
if isDropTarget {
|
||||
DropTargetIndicator()
|
||||
}
|
||||
InlineAddButton {
|
||||
addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId)
|
||||
}
|
||||
}
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let itemIdString = items.first,
|
||||
let itemId = UUID(uuidString: itemIdString),
|
||||
let item = customItems.first(where: { $0.id == itemId }) else { return false }
|
||||
Task {
|
||||
await moveItem(item, toDay: day, anchorType: anchorType, anchorId: anchorId)
|
||||
// Insert at beginning when dropping on add button area
|
||||
await moveItemToBeginning(item, toDay: day, anchorType: anchorType, anchorId: anchorId)
|
||||
}
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
return true
|
||||
} isTargeted: { targeted in
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a stable identifier for an itinerary section (for drop target tracking)
|
||||
private func sectionIdentifier(for section: ItinerarySection, at index: Int) -> String {
|
||||
switch section {
|
||||
case .day(let dayNumber, _, _):
|
||||
return "day-\(dayNumber)"
|
||||
case .travel(let segment):
|
||||
return "travel-\(segment.fromLocation.name)-\(segment.toLocation.name)"
|
||||
case .customItem(let item):
|
||||
return "item-\(item.id.uuidString)"
|
||||
case .addButton(let day, let anchorType, _):
|
||||
return "add-\(day)-\(anchorType.rawValue)"
|
||||
}
|
||||
}
|
||||
|
||||
private func findDayForTravelSegment(_ segment: TravelSegment) -> Int {
|
||||
// Find which day this travel segment belongs to by looking at sections
|
||||
// Travel appears BEFORE the arrival day, so look FORWARD to find arrival day
|
||||
for (index, section) in itinerarySections.enumerated() {
|
||||
if case .travel(let s) = section, s.id == segment.id {
|
||||
// Look backwards to find the day
|
||||
for i in stride(from: index - 1, through: 0, by: -1) {
|
||||
// Look forward to find the arrival day
|
||||
for i in (index + 1)..<itinerarySections.count {
|
||||
if case .day(let dayNumber, _, _) = itinerarySections[i] {
|
||||
return dayNumber
|
||||
}
|
||||
@@ -500,11 +573,19 @@ struct TripDetailView: View {
|
||||
return 1
|
||||
}
|
||||
|
||||
/// Create a stable anchor ID for a travel segment (UUIDs regenerate on reload)
|
||||
private func stableTravelAnchorId(_ segment: TravelSegment) -> String {
|
||||
let from = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
let to = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
return "travel:\(from)->\(to)"
|
||||
}
|
||||
|
||||
private func moveItem(_ item: CustomItineraryItem, toDay day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?, beforeItem: CustomItineraryItem? = nil) async {
|
||||
var updated = item
|
||||
updated.anchorDay = day
|
||||
updated.anchorType = anchorType
|
||||
updated.anchorId = anchorId
|
||||
updated.modifiedAt = Date()
|
||||
|
||||
// Calculate sortOrder
|
||||
let itemsAtSameAnchor = customItems.filter {
|
||||
@@ -514,20 +595,33 @@ struct TripDetailView: View {
|
||||
$0.id != item.id
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
|
||||
var itemsToSync: [CustomItineraryItem] = []
|
||||
|
||||
print("📍 [Move] itemsAtSameAnchor: \(itemsAtSameAnchor.map { "\($0.title) (id: \($0.id.uuidString.prefix(8)))" })")
|
||||
if let beforeItem = beforeItem {
|
||||
print("📍 [Move] beforeItem: \(beforeItem.title) (id: \(beforeItem.id.uuidString.prefix(8)))")
|
||||
}
|
||||
|
||||
if let beforeItem = beforeItem,
|
||||
let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) {
|
||||
updated.sortOrder = beforeIndex
|
||||
// Shift other items
|
||||
print("📍 [Move] Setting \(item.title) sortOrder to \(beforeIndex) (before \(beforeItem.title))")
|
||||
|
||||
// Shift other items and track them for syncing
|
||||
for i in beforeIndex..<itemsAtSameAnchor.count {
|
||||
if var shiftItem = customItems.first(where: { $0.id == itemsAtSameAnchor[i].id }) {
|
||||
shiftItem.sortOrder = i + 1
|
||||
shiftItem.modifiedAt = Date()
|
||||
print("📍 [Move] Shifting \(shiftItem.title) sortOrder to \(i + 1)")
|
||||
if let idx = customItems.firstIndex(where: { $0.id == shiftItem.id }) {
|
||||
customItems[idx] = shiftItem
|
||||
}
|
||||
itemsToSync.append(shiftItem)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updated.sortOrder = itemsAtSameAnchor.count
|
||||
print("📍 [Move] Setting \(item.title) sortOrder to \(itemsAtSameAnchor.count) (end of list)")
|
||||
}
|
||||
|
||||
// Update local state
|
||||
@@ -535,12 +629,71 @@ struct TripDetailView: View {
|
||||
customItems[idx] = updated
|
||||
}
|
||||
|
||||
// Sync moved item and all shifted items to CloudKit
|
||||
do {
|
||||
_ = try await CustomItemService.shared.updateItem(updated)
|
||||
print("✅ [Move] Synced \(updated.title) to day \(day), anchor: \(anchorType.rawValue), sortOrder: \(updated.sortOrder)")
|
||||
|
||||
// Also sync shifted items
|
||||
for shiftItem in itemsToSync {
|
||||
_ = try await CustomItemService.shared.updateItem(shiftItem)
|
||||
print("✅ [Move] Synced shifted item \(shiftItem.title) with sortOrder: \(shiftItem.sortOrder)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ [Move] Failed to sync: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Move item to the beginning of an anchor position (sortOrder 0), shifting existing items down
|
||||
private func moveItemToBeginning(_ item: CustomItineraryItem, toDay day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) async {
|
||||
var updated = item
|
||||
updated.anchorDay = day
|
||||
updated.anchorType = anchorType
|
||||
updated.anchorId = anchorId
|
||||
updated.sortOrder = 0 // Insert at beginning
|
||||
updated.modifiedAt = Date()
|
||||
|
||||
// Get existing items at this anchor position (excluding the moved item)
|
||||
let existingItems = customItems.filter {
|
||||
$0.anchorDay == day &&
|
||||
$0.anchorType == anchorType &&
|
||||
$0.anchorId == anchorId &&
|
||||
$0.id != item.id
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
|
||||
print("📍 [MoveToBeginning] Moving \(item.title) to beginning of day \(day), anchor: \(anchorType.rawValue)")
|
||||
print("📍 [MoveToBeginning] Existing items to shift: \(existingItems.map { "\($0.title) (order: \($0.sortOrder))" })")
|
||||
|
||||
// Shift all existing items down by 1
|
||||
var itemsToSync: [CustomItineraryItem] = []
|
||||
for (index, existingItem) in existingItems.enumerated() {
|
||||
if var shiftItem = customItems.first(where: { $0.id == existingItem.id }) {
|
||||
shiftItem.sortOrder = index + 1 // Shift down
|
||||
shiftItem.modifiedAt = Date()
|
||||
print("📍 [MoveToBeginning] Shifting \(shiftItem.title) sortOrder to \(index + 1)")
|
||||
if let idx = customItems.firstIndex(where: { $0.id == shiftItem.id }) {
|
||||
customItems[idx] = shiftItem
|
||||
}
|
||||
itemsToSync.append(shiftItem)
|
||||
}
|
||||
}
|
||||
|
||||
// Update local state for the moved item
|
||||
if let idx = customItems.firstIndex(where: { $0.id == item.id }) {
|
||||
customItems[idx] = updated
|
||||
}
|
||||
|
||||
// Sync to CloudKit
|
||||
do {
|
||||
_ = try await CustomItemService.shared.updateItem(updated)
|
||||
print("✅ [Move] Moved item to day \(day), anchor: \(anchorType.rawValue)")
|
||||
print("✅ [MoveToBeginning] Synced \(updated.title) with sortOrder: 0")
|
||||
|
||||
for shiftItem in itemsToSync {
|
||||
_ = try await CustomItemService.shared.updateItem(shiftItem)
|
||||
print("✅ [MoveToBeginning] Synced shifted item \(shiftItem.title) with sortOrder: \(shiftItem.sortOrder)")
|
||||
}
|
||||
} catch {
|
||||
print("❌ [Move] Failed to sync: \(error)")
|
||||
print("❌ [MoveToBeginning] Failed to sync: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -592,15 +745,18 @@ struct TripDetailView: View {
|
||||
sections.append(.travel(travelSegment))
|
||||
|
||||
if allowCustomItems {
|
||||
// Add button after travel
|
||||
sections.append(.addButton(day: section.dayNumber, anchorType: .afterTravel, anchorId: travelSegment.id.uuidString))
|
||||
// Use stable anchor ID for travel segments
|
||||
let stableId = stableTravelAnchorId(travelSegment)
|
||||
|
||||
// Custom items after this travel
|
||||
// Add button after travel
|
||||
sections.append(.addButton(day: section.dayNumber, anchorType: .afterTravel, anchorId: stableId))
|
||||
|
||||
// Custom items after this travel (sorted by sortOrder)
|
||||
let itemsAfterTravel = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber &&
|
||||
$0.anchorType == .afterTravel &&
|
||||
$0.anchorId == travelSegment.id.uuidString
|
||||
}
|
||||
$0.anchorId == stableId
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for item in itemsAfterTravel {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
@@ -610,10 +766,10 @@ struct TripDetailView: View {
|
||||
}
|
||||
|
||||
if allowCustomItems {
|
||||
// Custom items at start of day
|
||||
// Custom items at start of day (sorted by sortOrder)
|
||||
let itemsAtStart = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay
|
||||
}
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for item in itemsAtStart {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
@@ -627,12 +783,12 @@ struct TripDetailView: View {
|
||||
if let lastGame = section.games.last {
|
||||
sections.append(.addButton(day: section.dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id))
|
||||
|
||||
// Custom items after this game
|
||||
// Custom items after this game (sorted by sortOrder)
|
||||
let itemsAfterGame = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber &&
|
||||
$0.anchorType == .afterGame &&
|
||||
$0.anchorId == lastGame.game.id
|
||||
}
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for item in itemsAfterGame {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
@@ -910,7 +1066,7 @@ struct TripDetailView: View {
|
||||
let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id)
|
||||
print("✅ [CustomItems] Loaded \(items.count) items from CloudKit")
|
||||
for item in items {
|
||||
print(" - \(item.title) (day \(item.anchorDay), anchor: \(item.anchorType.rawValue))")
|
||||
print(" - \(item.title) (day \(item.anchorDay), anchor: \(item.anchorType.rawValue), sortOrder: \(item.sortOrder))")
|
||||
}
|
||||
customItems = items
|
||||
} catch {
|
||||
@@ -1005,6 +1161,28 @@ private struct InlineAddButton: View {
|
||||
.padding(.horizontal, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drop Target Indicator
|
||||
|
||||
private struct DropTargetIndicator: View {
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Circle()
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: 8, height: 8)
|
||||
Rectangle()
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(height: 2)
|
||||
Circle()
|
||||
.fill(Theme.warmOrange)
|
||||
.frame(width: 8, height: 8)
|
||||
}
|
||||
.padding(.horizontal, Theme.Spacing.md)
|
||||
.padding(.vertical, 4)
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.8)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1219,7 +1397,13 @@ struct TravelSection: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.background(Theme.cardBackground(colorScheme).opacity(0.7))
|
||||
.background {
|
||||
// Opaque base + semi-transparent accent (prevents line showing through)
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(Theme.cardBackground(colorScheme))
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
.fill(Theme.routeGold.opacity(0.05))
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||
|
||||
Reference in New Issue
Block a user