From aca394cefa2041f64beeec41ba3e331d31b76f0d Mon Sep 17 00:00:00 2001 From: Trey t Date: Fri, 16 Jan 2026 15:35:09 -0600 Subject: [PATCH] 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 --- .../Features/Trip/Views/CustomItemRow.swift | 13 +- .../Features/Trip/Views/TripDetailView.swift | 234 ++++++++++++++++-- 2 files changed, 220 insertions(+), 27 deletions(-) diff --git a/SportsTime/Features/Trip/Views/CustomItemRow.swift b/SportsTime/Features/Trip/Views/CustomItemRow.swift index 01fe624..e59b406 100644 --- a/SportsTime/Features/Trip/Views/CustomItemRow.swift +++ b/SportsTime/Features/Trip/Views/CustomItemRow.swift @@ -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 { diff --git a/SportsTime/Features/Trip/Views/TripDetailView.swift b/SportsTime/Features/Trip/Views/TripDetailView.swift index ad7ee10..d266233 100644 --- a/SportsTime/Features/Trip/Views/TripDetailView.swift +++ b/SportsTime/Features/Trip/Views/TripDetailView.swift @@ -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).. 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..