feat(itinerary): add draggable travel day positioning with CloudKit persistence
- Add TravelDayOverride model for storing user-customized travel day positions - Add TravelOverrideService for CloudKit CRUD operations on travel overrides - Add CKTravelDayOverride CloudKit model wrapper - Refactor itinerarySections to validate travel day bounds (must be after last game in departure city) - Travel segments can now be dragged to different days within valid range - Persist travel day overrides to CloudKit for cross-device sync Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -39,7 +39,9 @@ struct TripDetailView: View {
|
||||
@State private var editingItem: CustomItineraryItem?
|
||||
@State private var subscriptionCancellable: AnyCancellable?
|
||||
@State private var draggedItem: CustomItineraryItem?
|
||||
@State private var draggedTravelId: String? // Track which travel segment is being dragged
|
||||
@State private var dropTargetId: String? // Track which drop zone is being hovered
|
||||
@State private var travelDayOverrides: [String: Int] = [:] // Key: travel ID, Value: day number
|
||||
|
||||
private let exportService = ExportService()
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
@@ -104,6 +106,13 @@ struct TripDetailView: View {
|
||||
.padding(.horizontal, Theme.Spacing.lg)
|
||||
.padding(.bottom, Theme.Spacing.xxl)
|
||||
}
|
||||
// Catch-all drop handler - clears drag state and accepts the drop to end drag
|
||||
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: .constant(false)) { _ in
|
||||
draggedTravelId = nil
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
return true // Accept the drop to end the drag operation cleanly
|
||||
}
|
||||
}
|
||||
.background(Theme.backgroundGradient(colorScheme))
|
||||
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
||||
@@ -179,6 +188,11 @@ struct TripDetailView: View {
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
}
|
||||
.onChange(of: travelDayOverrides) { _, _ in
|
||||
// Clear drag state after travel move completed
|
||||
draggedTravelId = nil
|
||||
dropTargetId = nil
|
||||
}
|
||||
.overlay {
|
||||
if isExporting {
|
||||
exportProgressOverlay
|
||||
@@ -415,104 +429,114 @@ 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
|
||||
let isDragging = draggedItem != nil || draggedTravelId != nil
|
||||
let isDropTarget = dropTargetId == sectionId && isDragging
|
||||
|
||||
switch section {
|
||||
case .day(let dayNumber, let date, let gamesOnDay):
|
||||
// Show indicator at TOP for travel (travel appears above day), BOTTOM for custom items
|
||||
let indicatorAlignment: Alignment = draggedTravelId != nil ? .top : .bottom
|
||||
|
||||
// Pre-compute if this day is a valid travel target
|
||||
let isValidTravelTarget: Bool = {
|
||||
guard let travelId = draggedTravelId,
|
||||
let validRange = validDayRange(for: travelId) else { return true }
|
||||
return validRange.contains(dayNumber)
|
||||
}()
|
||||
|
||||
DaySection(
|
||||
dayNumber: dayNumber,
|
||||
date: date,
|
||||
games: gamesOnDay
|
||||
)
|
||||
.staggeredAnimation(index: index)
|
||||
.overlay(alignment: .bottom) {
|
||||
if isDropTarget {
|
||||
.overlay(alignment: indicatorAlignment) {
|
||||
// Only show indicator if valid target (or dragging custom item)
|
||||
if isDropTarget && (draggedTravelId == nil || isValidTravelTarget) {
|
||||
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 {
|
||||
// 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)
|
||||
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding(
|
||||
get: { dropTargetId == sectionId },
|
||||
set: { targeted in
|
||||
// Only show as target if it's a valid drop location
|
||||
let shouldShowTarget = targeted && (draggedTravelId == nil || isValidTravelTarget)
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
if shouldShowTarget {
|
||||
dropTargetId = sectionId
|
||||
} else if dropTargetId == sectionId {
|
||||
dropTargetId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
)) { providers in
|
||||
handleDayDrop(providers: providers, dayNumber: dayNumber, gamesOnDay: gamesOnDay)
|
||||
}
|
||||
|
||||
case .travel(let segment):
|
||||
let travelId = stableTravelAnchorId(segment)
|
||||
TravelSection(segment: segment)
|
||||
.staggeredAnimation(index: index)
|
||||
.overlay(alignment: .bottom) {
|
||||
if isDropTarget {
|
||||
// Show drop indicator for custom items, but not when dragging this travel
|
||||
if isDropTarget && draggedTravelId != travelId {
|
||||
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 {
|
||||
// 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)
|
||||
.onDrag {
|
||||
draggedTravelId = travelId
|
||||
return NSItemProvider(object: travelId as NSString)
|
||||
}
|
||||
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding(
|
||||
get: { dropTargetId == sectionId },
|
||||
set: { targeted in
|
||||
// Only accept custom items on travel, not other travel
|
||||
let shouldShow = targeted && draggedItem != nil
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
if shouldShow {
|
||||
dropTargetId = sectionId
|
||||
} else if dropTargetId == sectionId {
|
||||
dropTargetId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
)) { providers in
|
||||
handleTravelDrop(providers: providers, segment: segment)
|
||||
}
|
||||
|
||||
case .customItem(let item):
|
||||
let isDragging = draggedItem?.id == item.id
|
||||
let isDraggingThis = draggedItem?.id == item.id
|
||||
CustomItemRow(
|
||||
item: item,
|
||||
onTap: { editingItem = item },
|
||||
onDelete: { Task { await deleteCustomItem(item) } }
|
||||
)
|
||||
.opacity(isDragging ? 0.4 : 1.0)
|
||||
.opacity(isDraggingThis ? 0.4 : 1.0)
|
||||
.staggeredAnimation(index: index)
|
||||
.overlay(alignment: .top) {
|
||||
if isDropTarget && !isDragging {
|
||||
if isDropTarget && !isDraggingThis {
|
||||
DropTargetIndicator()
|
||||
}
|
||||
}
|
||||
.draggable(item.id.uuidString) {
|
||||
// Drag preview
|
||||
CustomItemRow(
|
||||
item: item,
|
||||
onTap: {},
|
||||
onDelete: {}
|
||||
)
|
||||
.frame(width: 300)
|
||||
.opacity(0.9)
|
||||
.onAppear { draggedItem = item }
|
||||
.onDrag {
|
||||
draggedItem = item
|
||||
return NSItemProvider(object: item.id.uuidString as NSString)
|
||||
}
|
||||
.dropDestination(for: String.self) { items, _ in
|
||||
guard let itemIdString = items.first,
|
||||
let itemId = UUID(uuidString: itemIdString),
|
||||
let droppedItem = customItems.first(where: { $0.id == itemId }),
|
||||
droppedItem.id != item.id else { return false }
|
||||
Task {
|
||||
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)
|
||||
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding(
|
||||
get: { dropTargetId == sectionId },
|
||||
set: { targeted in
|
||||
// Only accept custom items, not travel
|
||||
let shouldShow = targeted && draggedItem != nil && draggedItem?.id != item.id
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
if shouldShow {
|
||||
dropTargetId = sectionId
|
||||
} else if dropTargetId == sectionId {
|
||||
dropTargetId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
)) { providers in
|
||||
handleCustomItemDrop(providers: providers, targetItem: item)
|
||||
}
|
||||
|
||||
case .addButton(let day, let anchorType, let anchorId):
|
||||
@@ -524,25 +548,102 @@ struct TripDetailView: View {
|
||||
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 {
|
||||
// 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)
|
||||
.onDrop(of: [.text, .plainText, .utf8PlainText], isTargeted: Binding(
|
||||
get: { dropTargetId == sectionId },
|
||||
set: { targeted in
|
||||
// Only accept custom items, not travel
|
||||
let shouldShow = targeted && draggedItem != nil
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
if shouldShow {
|
||||
dropTargetId = sectionId
|
||||
} else if dropTargetId == sectionId {
|
||||
dropTargetId = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
)) { providers in
|
||||
handleAddButtonDrop(providers: providers, day: day, anchorType: anchorType, anchorId: anchorId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Drop Handlers
|
||||
|
||||
private func handleTravelDrop(providers: [NSItemProvider], segment: TravelSegment) -> Bool {
|
||||
guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Clear drag state immediately (synchronously) before async work
|
||||
draggedTravelId = nil
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
|
||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||
guard let droppedId = item as? String,
|
||||
let itemId = UUID(uuidString: droppedId),
|
||||
let droppedItem = self.customItems.first(where: { $0.id == itemId }) else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
let day = self.findDayForTravelSegment(segment)
|
||||
let stableAnchorId = self.stableTravelAnchorId(segment)
|
||||
await self.moveItemToBeginning(droppedItem, toDay: day, anchorType: .afterTravel, anchorId: stableAnchorId)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func handleCustomItemDrop(providers: [NSItemProvider], targetItem: CustomItineraryItem) -> Bool {
|
||||
guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Clear drag state immediately (synchronously) before async work
|
||||
draggedTravelId = nil
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
|
||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||
guard let droppedId = item as? String,
|
||||
let itemId = UUID(uuidString: droppedId),
|
||||
let droppedItem = self.customItems.first(where: { $0.id == itemId }),
|
||||
droppedItem.id != targetItem.id else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
await self.moveItem(droppedItem, toDay: targetItem.anchorDay, anchorType: targetItem.anchorType, anchorId: targetItem.anchorId, beforeItem: targetItem)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func handleAddButtonDrop(providers: [NSItemProvider], day: Int, anchorType: CustomItineraryItem.AnchorType, anchorId: String?) -> Bool {
|
||||
guard let provider = providers.first, provider.canLoadObject(ofClass: NSString.self) else {
|
||||
return false
|
||||
}
|
||||
|
||||
// Clear drag state immediately (synchronously) before async work
|
||||
draggedTravelId = nil
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
|
||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||
guard let droppedId = item as? String,
|
||||
let itemId = UUID(uuidString: droppedId),
|
||||
let droppedItem = self.customItems.first(where: { $0.id == itemId }) else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
await self.moveItemToBeginning(droppedItem, toDay: day, anchorType: anchorType, anchorId: anchorId)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func clearDragState() {
|
||||
draggedItem = nil
|
||||
draggedTravelId = nil
|
||||
dropTargetId = nil
|
||||
}
|
||||
|
||||
/// Create a stable identifier for an itinerary section (for drop target tracking)
|
||||
private func sectionIdentifier(for section: ItinerarySection, at index: Int) -> String {
|
||||
switch section {
|
||||
@@ -697,95 +798,98 @@ struct TripDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build itinerary sections: group by day AND city, with travel, custom items, and add buttons
|
||||
/// Build itinerary sections: shows ALL days with travel, custom items, and add buttons
|
||||
private var itinerarySections: [ItinerarySection] {
|
||||
var sections: [ItinerarySection] = []
|
||||
|
||||
// Build day+city sections for days with games
|
||||
var dayCitySections: [(dayNumber: Int, date: Date, city: String, games: [RichGame])] = []
|
||||
let days = tripDays
|
||||
|
||||
for (index, dayDate) in days.enumerated() {
|
||||
let dayNum = index + 1
|
||||
let gamesOnDay = gamesOn(date: dayDate)
|
||||
// Pre-calculate which day each travel segment belongs to
|
||||
// Default: day after last game in departure city, or use validated override
|
||||
var travelByDay: [Int: TravelSegment] = [:]
|
||||
for segment in trip.travelSegments {
|
||||
let travelId = stableTravelAnchorId(segment)
|
||||
let fromCity = segment.fromLocation.name
|
||||
let toCity = segment.toLocation.name
|
||||
|
||||
guard !gamesOnDay.isEmpty else { continue }
|
||||
// Calculate valid range for this travel
|
||||
// Travel can only happen AFTER the last game in departure city
|
||||
let lastGameInFromCity = findLastGameDay(in: fromCity)
|
||||
let firstGameInToCity = findFirstGameDay(in: toCity)
|
||||
let minDay = max(lastGameInFromCity + 1, 1)
|
||||
let maxDay = min(firstGameInToCity, tripDays.count)
|
||||
let validRange = minDay <= maxDay ? minDay...maxDay : minDay...minDay
|
||||
|
||||
// Group games by city, maintaining chronological order
|
||||
var gamesByCity: [(city: String, games: [RichGame])] = []
|
||||
for game in gamesOnDay {
|
||||
let city = game.stadium.city
|
||||
if let lastIndex = gamesByCity.indices.last, gamesByCity[lastIndex].city == city {
|
||||
// Same city as previous game - add to existing group
|
||||
gamesByCity[lastIndex].games.append(game)
|
||||
} else {
|
||||
// Different city - start new group
|
||||
gamesByCity.append((city, [game]))
|
||||
}
|
||||
// Calculate default day (day after last game in departure city)
|
||||
let defaultDay: Int
|
||||
if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count {
|
||||
defaultDay = lastGameInFromCity + 1
|
||||
} else if lastGameInFromCity > 0 {
|
||||
defaultDay = lastGameInFromCity
|
||||
} else {
|
||||
defaultDay = 1
|
||||
}
|
||||
|
||||
// Add each city group as a separate section
|
||||
for cityGroup in gamesByCity {
|
||||
dayCitySections.append((dayNum, dayDate, cityGroup.city, cityGroup.games))
|
||||
// Check for user override - only use if within valid range
|
||||
if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) {
|
||||
travelByDay[overrideDay] = segment
|
||||
} else {
|
||||
// Use default (clamped to valid range)
|
||||
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
|
||||
travelByDay[clampedDefault] = segment
|
||||
}
|
||||
}
|
||||
|
||||
// Build sections: insert travel, custom items (only if allowed), and add buttons
|
||||
for (index, section) in dayCitySections.enumerated() {
|
||||
// Process ALL days
|
||||
for (index, dayDate) in days.enumerated() {
|
||||
let dayNum = index + 1
|
||||
let gamesOnDay = gamesOn(date: dayDate)
|
||||
let isRestDay = gamesOnDay.isEmpty
|
||||
|
||||
// Check if we need travel BEFORE this section (coming from different city)
|
||||
if index > 0 {
|
||||
let prevSection = dayCitySections[index - 1]
|
||||
let prevCity = prevSection.city
|
||||
let currentCity = section.city
|
||||
// Travel for this day (if any)
|
||||
if let travelSegment = travelByDay[dayNum] {
|
||||
sections.append(.travel(travelSegment))
|
||||
|
||||
// If cities differ, find travel segment from prev -> current
|
||||
if !prevCity.isEmpty && !currentCity.isEmpty && prevCity != currentCity {
|
||||
if let travelSegment = findTravelSegment(from: prevCity, to: currentCity) {
|
||||
sections.append(.travel(travelSegment))
|
||||
if allowCustomItems {
|
||||
let stableId = stableTravelAnchorId(travelSegment)
|
||||
|
||||
if allowCustomItems {
|
||||
// Use stable anchor ID for travel segments
|
||||
let stableId = stableTravelAnchorId(travelSegment)
|
||||
// Add button after travel
|
||||
sections.append(.addButton(day: dayNum, anchorType: .afterTravel, anchorId: stableId))
|
||||
|
||||
// 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 == stableId
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for item in itemsAfterTravel {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
}
|
||||
// Custom items after this travel (sorted by sortOrder)
|
||||
let itemsAfterTravel = customItems.filter {
|
||||
$0.anchorType == .afterTravel && $0.anchorId == stableId
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for item in itemsAfterTravel {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom items at start of day (before games or as main content for rest days)
|
||||
if allowCustomItems {
|
||||
// Custom items at start of day (sorted by sortOrder)
|
||||
let itemsAtStart = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber && $0.anchorType == .startOfDay
|
||||
$0.anchorDay == dayNum && $0.anchorType == .startOfDay
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for item in itemsAtStart {
|
||||
sections.append(.customItem(item))
|
||||
}
|
||||
}
|
||||
|
||||
// Add the day section
|
||||
sections.append(.day(dayNumber: section.dayNumber, date: section.date, games: section.games))
|
||||
// Day section - shows games or minimal rest day display
|
||||
sections.append(.day(dayNumber: dayNum, date: dayDate, games: gamesOnDay))
|
||||
|
||||
// Add button after day (different anchor for game days vs rest days)
|
||||
if allowCustomItems {
|
||||
// Add button after day's games
|
||||
if let lastGame = section.games.last {
|
||||
sections.append(.addButton(day: section.dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id))
|
||||
if isRestDay {
|
||||
// Rest day: add button anchored to start of day (no games to anchor to)
|
||||
sections.append(.addButton(day: dayNum, anchorType: .startOfDay, anchorId: nil))
|
||||
} else if let lastGame = gamesOnDay.last {
|
||||
// Game day: add button anchored after last game
|
||||
sections.append(.addButton(day: dayNum, anchorType: .afterGame, anchorId: lastGame.game.id))
|
||||
|
||||
// Custom items after this game (sorted by sortOrder)
|
||||
let itemsAfterGame = customItems.filter {
|
||||
$0.anchorDay == section.dayNumber &&
|
||||
$0.anchorDay == dayNum &&
|
||||
$0.anchorType == .afterGame &&
|
||||
$0.anchorId == lastGame.game.id
|
||||
}.sorted { $0.sortOrder < $1.sortOrder }
|
||||
@@ -839,16 +943,63 @@ struct TripDetailView: View {
|
||||
}?.city
|
||||
}
|
||||
|
||||
/// Find travel segment that goes from one city to another
|
||||
private func findTravelSegment(from fromCity: String, to toCity: String) -> TravelSegment? {
|
||||
let fromLower = fromCity.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
let toLower = toCity.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
/// Find the last day number that has a game in the given city
|
||||
private func findLastGameDay(in city: String) -> Int {
|
||||
let cityLower = city.lowercased()
|
||||
let days = tripDays
|
||||
var lastDay = 0
|
||||
|
||||
return trip.travelSegments.first { segment in
|
||||
let segmentFrom = segment.fromLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
let segmentTo = segment.toLocation.name.lowercased().trimmingCharacters(in: .whitespaces)
|
||||
return segmentFrom == fromLower && segmentTo == toLower
|
||||
for (index, dayDate) in days.enumerated() {
|
||||
let dayNum = index + 1
|
||||
let gamesOnDay = gamesOn(date: dayDate)
|
||||
if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) {
|
||||
lastDay = dayNum
|
||||
}
|
||||
}
|
||||
return lastDay
|
||||
}
|
||||
|
||||
/// Find the first day number that has a game in the given city
|
||||
private func findFirstGameDay(in city: String) -> Int {
|
||||
let cityLower = city.lowercased()
|
||||
let days = tripDays
|
||||
|
||||
for (index, dayDate) in days.enumerated() {
|
||||
let dayNum = index + 1
|
||||
let gamesOnDay = gamesOn(date: dayDate)
|
||||
if gamesOnDay.contains(where: { $0.stadium.city.lowercased() == cityLower }) {
|
||||
return dayNum
|
||||
}
|
||||
}
|
||||
return tripDays.count // Default to last day if no games found
|
||||
}
|
||||
|
||||
/// Get valid day range for a travel segment
|
||||
/// Travel can be displayed from the day of last departure game to the day of first arrival game
|
||||
private func validDayRange(for travelId: String) -> ClosedRange<Int>? {
|
||||
// Find the segment matching this travel ID
|
||||
guard let segment = trip.travelSegments.first(where: { stableTravelAnchorId($0) == travelId }) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let fromCity = segment.fromLocation.name
|
||||
let toCity = segment.toLocation.name
|
||||
|
||||
// Travel can only happen AFTER the last game in departure city
|
||||
// So the earliest travel day is the day AFTER the last game
|
||||
let lastGameInFromCity = findLastGameDay(in: fromCity)
|
||||
let minDay = max(lastGameInFromCity + 1, 1)
|
||||
|
||||
// Travel must happen BEFORE or ON the first game day in arrival city
|
||||
let firstGameInToCity = findFirstGameDay(in: toCity)
|
||||
let maxDay = min(firstGameInToCity, tripDays.count)
|
||||
|
||||
// Handle edge case where minDay > maxDay shouldn't happen, but safeguard
|
||||
if minDay > maxDay {
|
||||
return minDay...minDay
|
||||
}
|
||||
|
||||
return minDay...maxDay
|
||||
}
|
||||
|
||||
// MARK: - Map Helpers
|
||||
@@ -1069,6 +1220,11 @@ struct TripDetailView: View {
|
||||
print(" - \(item.title) (day \(item.anchorDay), anchor: \(item.anchorType.rawValue), sortOrder: \(item.sortOrder))")
|
||||
}
|
||||
customItems = items
|
||||
|
||||
// Also load travel day overrides
|
||||
let overrides = try await TravelOverrideService.shared.fetchOverridesAsDictionary(forTripId: trip.id)
|
||||
print("✅ [TravelOverrides] Loaded \(overrides.count) travel day overrides")
|
||||
travelDayOverrides = overrides
|
||||
} catch {
|
||||
print("❌ [CustomItems] Failed to load: \(error)")
|
||||
}
|
||||
@@ -1118,6 +1274,78 @@ struct TripDetailView: View {
|
||||
print("❌ [CustomItems] CloudKit delete failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDayDrop(providers: [NSItemProvider], dayNumber: Int, gamesOnDay: [RichGame]) -> Bool {
|
||||
guard let provider = providers.first else { return false }
|
||||
|
||||
// Capture and clear drag state immediately (synchronously) before async work
|
||||
// This ensures the UI resets even if validation fails
|
||||
let capturedTravelId = draggedTravelId
|
||||
let capturedItem = draggedItem
|
||||
draggedTravelId = nil
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
|
||||
// Load the string from the provider
|
||||
if provider.canLoadObject(ofClass: NSString.self) {
|
||||
provider.loadObject(ofClass: NSString.self) { item, _ in
|
||||
guard let droppedId = item as? String else { return }
|
||||
|
||||
Task { @MainActor in
|
||||
// Check if this is a travel segment being dropped
|
||||
if droppedId.hasPrefix("travel:") {
|
||||
// Validate travel is within valid bounds
|
||||
if let validRange = self.validDayRange(for: droppedId) {
|
||||
guard validRange.contains(dayNumber) else {
|
||||
// Day is outside valid range - reject drop (state already cleared)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Move travel to this day
|
||||
withAnimation {
|
||||
self.travelDayOverrides[droppedId] = dayNumber
|
||||
}
|
||||
|
||||
// Persist the override to CloudKit
|
||||
await self.saveTravelDayOverride(travelAnchorId: droppedId, displayDay: dayNumber)
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, it's a custom item drop
|
||||
guard let itemId = UUID(uuidString: droppedId),
|
||||
let item = self.customItems.first(where: { $0.id == itemId }) else { return }
|
||||
|
||||
// For game days, anchor to last game; for rest days, anchor to start of day
|
||||
if let lastGame = gamesOnDay.last {
|
||||
await self.moveItemToBeginning(item, toDay: dayNumber, anchorType: .afterGame, anchorId: lastGame.game.id)
|
||||
} else {
|
||||
// Rest day - anchor to start of day
|
||||
await self.moveItemToBeginning(item, toDay: dayNumber, anchorType: .startOfDay, anchorId: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func saveTravelDayOverride(travelAnchorId: String, displayDay: Int) async {
|
||||
print("💾 [TravelOverrides] Saving override: \(travelAnchorId) -> day \(displayDay)")
|
||||
|
||||
let override = TravelDayOverride(
|
||||
tripId: trip.id,
|
||||
travelAnchorId: travelAnchorId,
|
||||
displayDay: displayDay
|
||||
)
|
||||
|
||||
do {
|
||||
_ = try await TravelOverrideService.shared.saveOverride(override)
|
||||
print("✅ [TravelOverrides] Saved to CloudKit")
|
||||
} catch {
|
||||
print("❌ [TravelOverrides] CloudKit save failed: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Itinerary Section
|
||||
@@ -1207,8 +1435,8 @@ struct DaySection: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Day header
|
||||
if isRestDay {
|
||||
// Minimal rest day display - just header with date
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Day \(dayNumber)")
|
||||
@@ -1221,29 +1449,44 @@ struct DaySection: View {
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.cardStyle()
|
||||
} else {
|
||||
// Full game day display
|
||||
VStack(alignment: .leading, spacing: Theme.Spacing.sm) {
|
||||
// Day header
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Day \(dayNumber)")
|
||||
.font(.title2)
|
||||
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||
|
||||
Text(formattedDate)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if isRestDay {
|
||||
Text("Rest Day")
|
||||
.badgeStyle(color: Theme.mlsGreen, filled: false)
|
||||
} else if !games.isEmpty {
|
||||
Text("\(games.count) game\(games.count > 1 ? "s" : "")")
|
||||
.badgeStyle(color: Theme.warmOrange, filled: false)
|
||||
}
|
||||
}
|
||||
|
||||
// City label
|
||||
if let city = gameCity {
|
||||
Label(city, systemImage: "mappin")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
// City label
|
||||
if let city = gameCity {
|
||||
Label(city, systemImage: "mappin")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||
}
|
||||
|
||||
// Games
|
||||
ForEach(games, id: \.game.id) { richGame in
|
||||
GameRow(game: richGame)
|
||||
// Games
|
||||
ForEach(games, id: \.game.id) { richGame in
|
||||
GameRow(game: richGame)
|
||||
}
|
||||
}
|
||||
.cardStyle()
|
||||
}
|
||||
.cardStyle()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user