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(.horizontal, Theme.Spacing.md)
|
||||||
.padding(.vertical, Theme.Spacing.sm)
|
.padding(.vertical, Theme.Spacing.sm)
|
||||||
.background(Theme.warmOrange.opacity(0.08))
|
.background {
|
||||||
.cornerRadius(8)
|
// 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)
|
.buttonStyle(.plain)
|
||||||
.contextMenu {
|
.contextMenu {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import SwiftUI
|
|||||||
import SwiftData
|
import SwiftData
|
||||||
import MapKit
|
import MapKit
|
||||||
import Combine
|
import Combine
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
struct TripDetailView: View {
|
struct TripDetailView: View {
|
||||||
@Environment(\.modelContext) private var modelContext
|
@Environment(\.modelContext) private var modelContext
|
||||||
@@ -38,6 +39,7 @@ struct TripDetailView: View {
|
|||||||
@State private var editingItem: CustomItineraryItem?
|
@State private var editingItem: CustomItineraryItem?
|
||||||
@State private var subscriptionCancellable: AnyCancellable?
|
@State private var subscriptionCancellable: AnyCancellable?
|
||||||
@State private var draggedItem: CustomItineraryItem?
|
@State private var draggedItem: CustomItineraryItem?
|
||||||
|
@State private var dropTargetId: String? // Track which drop zone is being hovered
|
||||||
|
|
||||||
private let exportService = ExportService()
|
private let exportService = ExportService()
|
||||||
private let dataProvider = AppDataProvider.shared
|
private let dataProvider = AppDataProvider.shared
|
||||||
@@ -172,6 +174,11 @@ struct TripDetailView: View {
|
|||||||
.onDisappear {
|
.onDisappear {
|
||||||
subscriptionCancellable?.cancel()
|
subscriptionCancellable?.cancel()
|
||||||
}
|
}
|
||||||
|
.onChange(of: customItems) { _, _ in
|
||||||
|
// Clear drag state after items update (move completed)
|
||||||
|
draggedItem = nil
|
||||||
|
dropTargetId = nil
|
||||||
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
if isExporting {
|
if isExporting {
|
||||||
exportProgressOverlay
|
exportProgressOverlay
|
||||||
@@ -407,6 +414,9 @@ struct TripDetailView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func itineraryRow(for section: ItinerarySection, at index: Int) -> some View {
|
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 {
|
switch section {
|
||||||
case .day(let dayNumber, let date, let gamesOnDay):
|
case .day(let dayNumber, let date, let gamesOnDay):
|
||||||
DaySection(
|
DaySection(
|
||||||
@@ -415,39 +425,68 @@ struct TripDetailView: View {
|
|||||||
games: gamesOnDay
|
games: gamesOnDay
|
||||||
)
|
)
|
||||||
.staggeredAnimation(index: index)
|
.staggeredAnimation(index: index)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if isDropTarget {
|
||||||
|
DropTargetIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
.dropDestination(for: String.self) { items, _ in
|
.dropDestination(for: String.self) { items, _ in
|
||||||
guard let itemIdString = items.first,
|
guard let itemIdString = items.first,
|
||||||
let itemId = UUID(uuidString: itemIdString),
|
let itemId = UUID(uuidString: itemIdString),
|
||||||
let item = customItems.first(where: { $0.id == itemId }),
|
let item = customItems.first(where: { $0.id == itemId }),
|
||||||
let lastGame = gamesOnDay.last else { return false }
|
let lastGame = gamesOnDay.last else { return false }
|
||||||
Task {
|
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
|
return true
|
||||||
|
} isTargeted: { targeted in
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case .travel(let segment):
|
case .travel(let segment):
|
||||||
TravelSection(segment: segment)
|
TravelSection(segment: segment)
|
||||||
.staggeredAnimation(index: index)
|
.staggeredAnimation(index: index)
|
||||||
|
.overlay(alignment: .bottom) {
|
||||||
|
if isDropTarget {
|
||||||
|
DropTargetIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
.dropDestination(for: String.self) { items, _ in
|
.dropDestination(for: String.self) { items, _ in
|
||||||
guard let itemIdString = items.first,
|
guard let itemIdString = items.first,
|
||||||
let itemId = UUID(uuidString: itemIdString),
|
let itemId = UUID(uuidString: itemIdString),
|
||||||
let item = customItems.first(where: { $0.id == itemId }) else { return false }
|
let item = customItems.first(where: { $0.id == itemId }) else { return false }
|
||||||
// Find the day for this travel segment
|
// Find the day for this travel segment
|
||||||
let day = findDayForTravelSegment(segment)
|
let day = findDayForTravelSegment(segment)
|
||||||
|
// Use stable identifier instead of UUID (UUIDs change on reload)
|
||||||
|
let stableAnchorId = stableTravelAnchorId(segment)
|
||||||
Task {
|
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
|
return true
|
||||||
|
} isTargeted: { targeted in
|
||||||
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
dropTargetId = targeted ? sectionId : (dropTargetId == sectionId ? nil : dropTargetId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
case .customItem(let item):
|
case .customItem(let item):
|
||||||
|
let isDragging = draggedItem?.id == item.id
|
||||||
CustomItemRow(
|
CustomItemRow(
|
||||||
item: item,
|
item: item,
|
||||||
onTap: { editingItem = item },
|
onTap: { editingItem = item },
|
||||||
onDelete: { Task { await deleteCustomItem(item) } }
|
onDelete: { Task { await deleteCustomItem(item) } }
|
||||||
)
|
)
|
||||||
|
.opacity(isDragging ? 0.4 : 1.0)
|
||||||
.staggeredAnimation(index: index)
|
.staggeredAnimation(index: index)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
if isDropTarget && !isDragging {
|
||||||
|
DropTargetIndicator()
|
||||||
|
}
|
||||||
|
}
|
||||||
.draggable(item.id.uuidString) {
|
.draggable(item.id.uuidString) {
|
||||||
// Drag preview
|
// Drag preview
|
||||||
CustomItemRow(
|
CustomItemRow(
|
||||||
@@ -456,41 +495,75 @@ struct TripDetailView: View {
|
|||||||
onDelete: {}
|
onDelete: {}
|
||||||
)
|
)
|
||||||
.frame(width: 300)
|
.frame(width: 300)
|
||||||
.opacity(0.8)
|
.opacity(0.9)
|
||||||
|
.onAppear { draggedItem = item }
|
||||||
}
|
}
|
||||||
.dropDestination(for: String.self) { items, _ in
|
.dropDestination(for: String.self) { items, _ in
|
||||||
guard let itemIdString = items.first,
|
guard let itemIdString = items.first,
|
||||||
let itemId = UUID(uuidString: itemIdString),
|
let itemId = UUID(uuidString: itemIdString),
|
||||||
let draggedItem = customItems.first(where: { $0.id == itemId }),
|
let droppedItem = customItems.first(where: { $0.id == itemId }),
|
||||||
draggedItem.id != item.id else { return false }
|
droppedItem.id != item.id else { return false }
|
||||||
Task {
|
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
|
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):
|
case .addButton(let day, let anchorType, let anchorId):
|
||||||
InlineAddButton {
|
VStack(spacing: 0) {
|
||||||
addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId)
|
if isDropTarget {
|
||||||
|
DropTargetIndicator()
|
||||||
|
}
|
||||||
|
InlineAddButton {
|
||||||
|
addItemAnchor = AddItemAnchor(day: day, type: anchorType, anchorId: anchorId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.dropDestination(for: String.self) { items, _ in
|
.dropDestination(for: String.self) { items, _ in
|
||||||
guard let itemIdString = items.first,
|
guard let itemIdString = items.first,
|
||||||
let itemId = UUID(uuidString: itemIdString),
|
let itemId = UUID(uuidString: itemIdString),
|
||||||
let item = customItems.first(where: { $0.id == itemId }) else { return false }
|
let item = customItems.first(where: { $0.id == itemId }) else { return false }
|
||||||
Task {
|
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
|
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 {
|
private func findDayForTravelSegment(_ segment: TravelSegment) -> Int {
|
||||||
// Find which day this travel segment belongs to by looking at sections
|
// 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() {
|
for (index, section) in itinerarySections.enumerated() {
|
||||||
if case .travel(let s) = section, s.id == segment.id {
|
if case .travel(let s) = section, s.id == segment.id {
|
||||||
// Look backwards to find the day
|
// Look forward to find the arrival day
|
||||||
for i in stride(from: index - 1, through: 0, by: -1) {
|
for i in (index + 1)..<itinerarySections.count {
|
||||||
if case .day(let dayNumber, _, _) = itinerarySections[i] {
|
if case .day(let dayNumber, _, _) = itinerarySections[i] {
|
||||||
return dayNumber
|
return dayNumber
|
||||||
}
|
}
|
||||||
@@ -500,11 +573,19 @@ struct TripDetailView: View {
|
|||||||
return 1
|
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 {
|
private func moveItem(_ item: CustomItineraryItem, toDay day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?, beforeItem: CustomItineraryItem? = nil) async {
|
||||||
var updated = item
|
var updated = item
|
||||||
updated.anchorDay = day
|
updated.anchorDay = day
|
||||||
updated.anchorType = anchorType
|
updated.anchorType = anchorType
|
||||||
updated.anchorId = anchorId
|
updated.anchorId = anchorId
|
||||||
|
updated.modifiedAt = Date()
|
||||||
|
|
||||||
// Calculate sortOrder
|
// Calculate sortOrder
|
||||||
let itemsAtSameAnchor = customItems.filter {
|
let itemsAtSameAnchor = customItems.filter {
|
||||||
@@ -514,20 +595,33 @@ struct TripDetailView: View {
|
|||||||
$0.id != item.id
|
$0.id != item.id
|
||||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
}.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,
|
if let beforeItem = beforeItem,
|
||||||
let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) {
|
let beforeIndex = itemsAtSameAnchor.firstIndex(where: { $0.id == beforeItem.id }) {
|
||||||
updated.sortOrder = beforeIndex
|
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 {
|
for i in beforeIndex..<itemsAtSameAnchor.count {
|
||||||
if var shiftItem = customItems.first(where: { $0.id == itemsAtSameAnchor[i].id }) {
|
if var shiftItem = customItems.first(where: { $0.id == itemsAtSameAnchor[i].id }) {
|
||||||
shiftItem.sortOrder = i + 1
|
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 }) {
|
if let idx = customItems.firstIndex(where: { $0.id == shiftItem.id }) {
|
||||||
customItems[idx] = shiftItem
|
customItems[idx] = shiftItem
|
||||||
}
|
}
|
||||||
|
itemsToSync.append(shiftItem)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
updated.sortOrder = itemsAtSameAnchor.count
|
updated.sortOrder = itemsAtSameAnchor.count
|
||||||
|
print("📍 [Move] Setting \(item.title) sortOrder to \(itemsAtSameAnchor.count) (end of list)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update local state
|
// Update local state
|
||||||
@@ -535,12 +629,71 @@ struct TripDetailView: View {
|
|||||||
customItems[idx] = updated
|
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
|
// Sync to CloudKit
|
||||||
do {
|
do {
|
||||||
_ = try await CustomItemService.shared.updateItem(updated)
|
_ = 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 {
|
} catch {
|
||||||
print("❌ [Move] Failed to sync: \(error)")
|
print("❌ [MoveToBeginning] Failed to sync: \(error)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -592,15 +745,18 @@ struct TripDetailView: View {
|
|||||||
sections.append(.travel(travelSegment))
|
sections.append(.travel(travelSegment))
|
||||||
|
|
||||||
if allowCustomItems {
|
if allowCustomItems {
|
||||||
// Add button after travel
|
// Use stable anchor ID for travel segments
|
||||||
sections.append(.addButton(day: section.dayNumber, anchorType: .afterTravel, anchorId: travelSegment.id.uuidString))
|
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 {
|
let itemsAfterTravel = customItems.filter {
|
||||||
$0.anchorDay == section.dayNumber &&
|
$0.anchorDay == section.dayNumber &&
|
||||||
$0.anchorType == .afterTravel &&
|
$0.anchorType == .afterTravel &&
|
||||||
$0.anchorId == travelSegment.id.uuidString
|
$0.anchorId == stableId
|
||||||
}
|
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
for item in itemsAfterTravel {
|
for item in itemsAfterTravel {
|
||||||
sections.append(.customItem(item))
|
sections.append(.customItem(item))
|
||||||
}
|
}
|
||||||
@@ -610,10 +766,10 @@ struct TripDetailView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if allowCustomItems {
|
if allowCustomItems {
|
||||||
// Custom items at start of day
|
// Custom items at start of day (sorted by sortOrder)
|
||||||
let itemsAtStart = customItems.filter {
|
let itemsAtStart = customItems.filter {
|
||||||
$0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay
|
$0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay
|
||||||
}
|
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
for item in itemsAtStart {
|
for item in itemsAtStart {
|
||||||
sections.append(.customItem(item))
|
sections.append(.customItem(item))
|
||||||
}
|
}
|
||||||
@@ -627,12 +783,12 @@ struct TripDetailView: View {
|
|||||||
if let lastGame = section.games.last {
|
if let lastGame = section.games.last {
|
||||||
sections.append(.addButton(day: section.dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id))
|
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 {
|
let itemsAfterGame = customItems.filter {
|
||||||
$0.anchorDay == section.dayNumber &&
|
$0.anchorDay == section.dayNumber &&
|
||||||
$0.anchorType == .afterGame &&
|
$0.anchorType == .afterGame &&
|
||||||
$0.anchorId == lastGame.game.id
|
$0.anchorId == lastGame.game.id
|
||||||
}
|
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||||
for item in itemsAfterGame {
|
for item in itemsAfterGame {
|
||||||
sections.append(.customItem(item))
|
sections.append(.customItem(item))
|
||||||
}
|
}
|
||||||
@@ -910,7 +1066,7 @@ struct TripDetailView: View {
|
|||||||
let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id)
|
let items = try await CustomItemService.shared.fetchItems(forTripId: trip.id)
|
||||||
print("✅ [CustomItems] Loaded \(items.count) items from CloudKit")
|
print("✅ [CustomItems] Loaded \(items.count) items from CloudKit")
|
||||||
for item in items {
|
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
|
customItems = items
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1005,6 +1161,28 @@ private struct InlineAddButton: View {
|
|||||||
.padding(.horizontal, 8)
|
.padding(.horizontal, 8)
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.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))
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.medium))
|
||||||
.overlay {
|
.overlay {
|
||||||
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
RoundedRectangle(cornerRadius: Theme.CornerRadius.medium)
|
||||||
|
|||||||
Reference in New Issue
Block a user