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:
@@ -21,6 +21,7 @@ enum CKRecordType {
|
||||
static let tripPoll = "TripPoll"
|
||||
static let pollVote = "PollVote"
|
||||
static let customItineraryItem = "CustomItineraryItem"
|
||||
static let travelDayOverride = "TravelDayOverride"
|
||||
}
|
||||
|
||||
// MARK: - CKTeam
|
||||
@@ -694,3 +695,55 @@ struct CKCustomItineraryItem {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CKTravelDayOverride
|
||||
|
||||
struct CKTravelDayOverride {
|
||||
static let overrideIdKey = "overrideId"
|
||||
static let tripIdKey = "tripId"
|
||||
static let travelAnchorIdKey = "travelAnchorId"
|
||||
static let displayDayKey = "displayDay"
|
||||
static let createdAtKey = "createdAt"
|
||||
static let modifiedAtKey = "modifiedAt"
|
||||
|
||||
let record: CKRecord
|
||||
|
||||
init(record: CKRecord) {
|
||||
self.record = record
|
||||
}
|
||||
|
||||
init(override: TravelDayOverride) {
|
||||
let record = CKRecord(
|
||||
recordType: CKRecordType.travelDayOverride,
|
||||
recordID: CKRecord.ID(recordName: override.id.uuidString)
|
||||
)
|
||||
record[CKTravelDayOverride.overrideIdKey] = override.id.uuidString
|
||||
record[CKTravelDayOverride.tripIdKey] = override.tripId.uuidString
|
||||
record[CKTravelDayOverride.travelAnchorIdKey] = override.travelAnchorId
|
||||
record[CKTravelDayOverride.displayDayKey] = override.displayDay
|
||||
record[CKTravelDayOverride.createdAtKey] = override.createdAt
|
||||
record[CKTravelDayOverride.modifiedAtKey] = override.modifiedAt
|
||||
self.record = record
|
||||
}
|
||||
|
||||
func toOverride() -> TravelDayOverride? {
|
||||
guard let overrideIdString = record[CKTravelDayOverride.overrideIdKey] as? String,
|
||||
let overrideId = UUID(uuidString: overrideIdString),
|
||||
let tripIdString = record[CKTravelDayOverride.tripIdKey] as? String,
|
||||
let tripId = UUID(uuidString: tripIdString),
|
||||
let travelAnchorId = record[CKTravelDayOverride.travelAnchorIdKey] as? String,
|
||||
let displayDay = record[CKTravelDayOverride.displayDayKey] as? Int,
|
||||
let createdAt = record[CKTravelDayOverride.createdAtKey] as? Date,
|
||||
let modifiedAt = record[CKTravelDayOverride.modifiedAtKey] as? Date
|
||||
else { return nil }
|
||||
|
||||
return TravelDayOverride(
|
||||
id: overrideId,
|
||||
tripId: tripId,
|
||||
travelAnchorId: travelAnchorId,
|
||||
displayDay: displayDay,
|
||||
createdAt: createdAt,
|
||||
modifiedAt: modifiedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
38
SportsTime/Core/Models/Domain/TravelDayOverride.swift
Normal file
38
SportsTime/Core/Models/Domain/TravelDayOverride.swift
Normal file
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// TravelDayOverride.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Stores user-customized travel day positions
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Represents a user override of the default travel day placement.
|
||||
/// Travel segments normally appear on the day after the last game in the departure city.
|
||||
/// This model allows users to drag travel to a different day within valid bounds.
|
||||
struct TravelDayOverride: Identifiable, Codable, Hashable {
|
||||
let id: UUID
|
||||
let tripId: UUID
|
||||
/// Stable identifier for the travel segment (e.g., "travel:houston->arlington")
|
||||
let travelAnchorId: String
|
||||
/// The day number (1-indexed) where the travel should display
|
||||
var displayDay: Int
|
||||
let createdAt: Date
|
||||
var modifiedAt: Date
|
||||
|
||||
init(
|
||||
id: UUID = UUID(),
|
||||
tripId: UUID,
|
||||
travelAnchorId: String,
|
||||
displayDay: Int,
|
||||
createdAt: Date = Date(),
|
||||
modifiedAt: Date = Date()
|
||||
) {
|
||||
self.id = id
|
||||
self.tripId = tripId
|
||||
self.travelAnchorId = travelAnchorId
|
||||
self.displayDay = displayDay
|
||||
self.createdAt = createdAt
|
||||
self.modifiedAt = modifiedAt
|
||||
}
|
||||
}
|
||||
180
SportsTime/Core/Services/TravelOverrideService.swift
Normal file
180
SportsTime/Core/Services/TravelOverrideService.swift
Normal file
@@ -0,0 +1,180 @@
|
||||
//
|
||||
// TravelOverrideService.swift
|
||||
// SportsTime
|
||||
//
|
||||
// CloudKit service for travel day position overrides
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CloudKit
|
||||
|
||||
actor TravelOverrideService {
|
||||
static let shared = TravelOverrideService()
|
||||
|
||||
private let container: CKContainer
|
||||
private let publicDatabase: CKDatabase
|
||||
|
||||
private init() {
|
||||
self.container = CKContainer(identifier: "iCloud.com.sportstime.app")
|
||||
self.publicDatabase = container.publicCloudDatabase
|
||||
}
|
||||
|
||||
// MARK: - CRUD Operations
|
||||
|
||||
/// Create or update a travel day override (upserts by tripId + travelAnchorId)
|
||||
func saveOverride(_ override: TravelDayOverride) async throws -> TravelDayOverride {
|
||||
print("☁️ [CloudKit] Saving travel override for: \(override.travelAnchorId)")
|
||||
|
||||
// Look up existing override by travelAnchorId (not by UUID)
|
||||
let existingOverrides = try await fetchOverrides(forTripId: override.tripId)
|
||||
let existingOverride = existingOverrides.first { $0.travelAnchorId == override.travelAnchorId }
|
||||
|
||||
let record: CKRecord
|
||||
let now = Date()
|
||||
|
||||
if let existing = existingOverride {
|
||||
// Fetch the actual CKRecord to update it
|
||||
let recordID = CKRecord.ID(recordName: existing.id.uuidString)
|
||||
do {
|
||||
let existingRecord = try await publicDatabase.record(for: recordID)
|
||||
existingRecord[CKTravelDayOverride.displayDayKey] = override.displayDay
|
||||
existingRecord[CKTravelDayOverride.modifiedAtKey] = now
|
||||
record = existingRecord
|
||||
print("☁️ [CloudKit] Updating existing override record: \(existing.id)")
|
||||
} catch {
|
||||
// Record doesn't exist anymore, create new
|
||||
let ckOverride = CKTravelDayOverride(override: override)
|
||||
record = ckOverride.record
|
||||
}
|
||||
} else {
|
||||
// Create new record
|
||||
let ckOverride = CKTravelDayOverride(override: override)
|
||||
record = ckOverride.record
|
||||
print("☁️ [CloudKit] Creating new override record")
|
||||
}
|
||||
|
||||
do {
|
||||
try await publicDatabase.save(record)
|
||||
print("☁️ [CloudKit] Travel override saved: \(record.recordID.recordName)")
|
||||
var savedOverride = override
|
||||
savedOverride.modifiedAt = now
|
||||
return savedOverride
|
||||
} catch let error as CKError {
|
||||
print("☁️ [CloudKit] CKError on save: \(error.code.rawValue) - \(error.localizedDescription)")
|
||||
throw mapCloudKitError(error)
|
||||
} catch {
|
||||
print("☁️ [CloudKit] Unknown error on save: \(error)")
|
||||
throw TravelOverrideError.unknown(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a travel day override
|
||||
func deleteOverride(_ overrideId: UUID) async throws {
|
||||
let recordID = CKRecord.ID(recordName: overrideId.uuidString)
|
||||
|
||||
do {
|
||||
try await publicDatabase.deleteRecord(withID: recordID)
|
||||
print("☁️ [CloudKit] Travel override deleted: \(overrideId)")
|
||||
} catch let error as CKError {
|
||||
if error.code != .unknownItem {
|
||||
throw mapCloudKitError(error)
|
||||
}
|
||||
// Already deleted - ignore
|
||||
} catch {
|
||||
throw TravelOverrideError.unknown(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete override by travel anchor ID (useful when removing by travel segment)
|
||||
func deleteOverride(forTripId tripId: UUID, travelAnchorId: String) async throws {
|
||||
// First fetch to find the record
|
||||
let overrides = try await fetchOverrides(forTripId: tripId)
|
||||
if let existing = overrides.first(where: { $0.travelAnchorId == travelAnchorId }) {
|
||||
try await deleteOverride(existing.id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch all travel day overrides for a trip
|
||||
func fetchOverrides(forTripId tripId: UUID) async throws -> [TravelDayOverride] {
|
||||
print("☁️ [CloudKit] Fetching travel overrides for tripId: \(tripId.uuidString)")
|
||||
let predicate = NSPredicate(
|
||||
format: "%K == %@",
|
||||
CKTravelDayOverride.tripIdKey,
|
||||
tripId.uuidString
|
||||
)
|
||||
let query = CKQuery(recordType: CKRecordType.travelDayOverride, predicate: predicate)
|
||||
|
||||
do {
|
||||
let (results, _) = try await publicDatabase.records(matching: query)
|
||||
print("☁️ [CloudKit] Query returned \(results.count) travel override records")
|
||||
|
||||
let overrides = results.compactMap { result -> TravelDayOverride? in
|
||||
guard case .success(let record) = result.1 else {
|
||||
print("☁️ [CloudKit] Record fetch failed for: \(result.0.recordName)")
|
||||
return nil
|
||||
}
|
||||
let override = CKTravelDayOverride(record: record).toOverride()
|
||||
if override == nil {
|
||||
print("☁️ [CloudKit] Failed to parse travel override record: \(record.recordID.recordName)")
|
||||
}
|
||||
return override
|
||||
}
|
||||
|
||||
print("☁️ [CloudKit] Parsed \(overrides.count) valid travel overrides")
|
||||
return overrides
|
||||
} catch let error as CKError {
|
||||
print("☁️ [CloudKit] CKError on fetch: \(error.code.rawValue) - \(error.localizedDescription)")
|
||||
throw mapCloudKitError(error)
|
||||
} catch {
|
||||
print("☁️ [CloudKit] Unknown error on fetch: \(error)")
|
||||
throw TravelOverrideError.unknown(error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert array of overrides to dictionary keyed by travelAnchorId
|
||||
/// If duplicate travelAnchorIds exist (legacy data), takes the most recently modified one
|
||||
func fetchOverridesAsDictionary(forTripId tripId: UUID) async throws -> [String: Int] {
|
||||
let overrides = try await fetchOverrides(forTripId: tripId)
|
||||
// Sort by modifiedAt descending so most recent comes first
|
||||
let sorted = overrides.sorted { $0.modifiedAt > $1.modifiedAt }
|
||||
// Use uniquingKeysWith to keep the first (most recent) value for duplicates
|
||||
return Dictionary(sorted.map { ($0.travelAnchorId, $0.displayDay) }) { first, _ in first }
|
||||
}
|
||||
|
||||
// MARK: - Error Mapping
|
||||
|
||||
private func mapCloudKitError(_ error: CKError) -> TravelOverrideError {
|
||||
switch error.code {
|
||||
case .notAuthenticated:
|
||||
return .notSignedIn
|
||||
case .networkUnavailable, .networkFailure:
|
||||
return .networkUnavailable
|
||||
case .unknownItem:
|
||||
return .overrideNotFound
|
||||
default:
|
||||
return .unknown(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum TravelOverrideError: Error, LocalizedError {
|
||||
case notSignedIn
|
||||
case overrideNotFound
|
||||
case networkUnavailable
|
||||
case unknown(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notSignedIn:
|
||||
return "Please sign in to iCloud to customize travel days."
|
||||
case .overrideNotFound:
|
||||
return "Travel day setting not found."
|
||||
case .networkUnavailable:
|
||||
return "Unable to connect. Please check your internet connection."
|
||||
case .unknown(let error):
|
||||
return "An error occurred: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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