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:
Trey t
2026-01-16 15:35:09 -06:00
parent ae88dd3807
commit aca394cefa
2 changed files with 220 additions and 27 deletions

View File

@@ -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 {

View File

@@ -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)