wip
This commit is contained in:
@@ -2,7 +2,12 @@
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Skill(superpowers:brainstorming)",
|
||||
"Skill(superpowers:writing-plans)"
|
||||
"Skill(superpowers:writing-plans)",
|
||||
"Skill(superpowers:using-git-worktrees)",
|
||||
"Skill(superpowers:subagent-driven-development)",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"WebSearch"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,27 +9,27 @@ import SwiftUI
|
||||
|
||||
struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresentable {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
|
||||
|
||||
let trip: Trip
|
||||
let games: [RichGame]
|
||||
let itineraryItems: [ItineraryItem]
|
||||
let travelDayOverrides: [String: Int]
|
||||
let travelOverrides: [String: TravelOverride]
|
||||
let headerContent: HeaderContent
|
||||
|
||||
|
||||
// Callbacks
|
||||
var onTravelMoved: ((String, Int) -> Void)?
|
||||
var onTravelMoved: ((String, Int, Double) -> Void)?
|
||||
var onCustomItemMoved: ((UUID, Int, Double) -> Void)? // itemId, newDay, newSortOrder
|
||||
var onCustomItemTapped: ((ItineraryItem) -> Void)?
|
||||
var onCustomItemDeleted: ((ItineraryItem) -> Void)?
|
||||
var onAddButtonTapped: ((Int) -> Void)? // Just day number
|
||||
|
||||
|
||||
init(
|
||||
trip: Trip,
|
||||
games: [RichGame],
|
||||
itineraryItems: [ItineraryItem],
|
||||
travelDayOverrides: [String: Int],
|
||||
travelOverrides: [String: TravelOverride],
|
||||
@ViewBuilder headerContent: () -> HeaderContent,
|
||||
onTravelMoved: ((String, Int) -> Void)? = nil,
|
||||
onTravelMoved: ((String, Int, Double) -> Void)? = nil,
|
||||
onCustomItemMoved: ((UUID, Int, Double) -> Void)? = nil,
|
||||
onCustomItemTapped: ((ItineraryItem) -> Void)? = nil,
|
||||
onCustomItemDeleted: ((ItineraryItem) -> Void)? = nil,
|
||||
@@ -38,7 +38,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
self.trip = trip
|
||||
self.games = games
|
||||
self.itineraryItems = itineraryItems
|
||||
self.travelDayOverrides = travelDayOverrides
|
||||
self.travelOverrides = travelOverrides
|
||||
self.headerContent = headerContent()
|
||||
self.onTravelMoved = onTravelMoved
|
||||
self.onCustomItemMoved = onCustomItemMoved
|
||||
@@ -46,15 +46,15 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
self.onCustomItemDeleted = onCustomItemDeleted
|
||||
self.onAddButtonTapped = onAddButtonTapped
|
||||
}
|
||||
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
}
|
||||
|
||||
|
||||
class Coordinator {
|
||||
var headerHostingController: UIHostingController<HeaderContent>?
|
||||
}
|
||||
|
||||
|
||||
func makeUIViewController(context: Context) -> ItineraryTableViewController {
|
||||
let controller = ItineraryTableViewController(style: .plain)
|
||||
controller.colorScheme = colorScheme
|
||||
@@ -63,14 +63,14 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
controller.onCustomItemTapped = onCustomItemTapped
|
||||
controller.onCustomItemDeleted = onCustomItemDeleted
|
||||
controller.onAddButtonTapped = onAddButtonTapped
|
||||
|
||||
|
||||
// Set header with proper sizing
|
||||
let hostingController = UIHostingController(rootView: headerContent)
|
||||
hostingController.view.backgroundColor = .clear
|
||||
|
||||
|
||||
// Store in coordinator for later updates
|
||||
context.coordinator.headerHostingController = hostingController
|
||||
|
||||
|
||||
// Pre-size the header view
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||
let targetWidth = UIScreen.main.bounds.width
|
||||
@@ -78,16 +78,16 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
let size = hostingController.view.systemLayoutSizeFitting(targetSize, withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel)
|
||||
hostingController.view.frame = CGRect(origin: .zero, size: CGSize(width: targetWidth, height: max(size.height, 450)))
|
||||
hostingController.view.translatesAutoresizingMaskIntoConstraints = true
|
||||
|
||||
|
||||
controller.setTableHeader(hostingController.view)
|
||||
|
||||
|
||||
// Load initial data
|
||||
let (days, validRanges) = buildItineraryData()
|
||||
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: itineraryItems)
|
||||
|
||||
let (days, validRanges, allItemsForConstraints) = buildItineraryData()
|
||||
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
|
||||
func updateUIViewController(_ controller: ItineraryTableViewController, context: Context) {
|
||||
controller.colorScheme = colorScheme
|
||||
controller.onTravelMoved = onTravelMoved
|
||||
@@ -95,125 +95,194 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
controller.onCustomItemTapped = onCustomItemTapped
|
||||
controller.onCustomItemDeleted = onCustomItemDeleted
|
||||
controller.onAddButtonTapped = onAddButtonTapped
|
||||
|
||||
|
||||
// Update header content by updating the hosting controller's rootView
|
||||
// This avoids recreating the view hierarchy and prevents infinite loops
|
||||
context.coordinator.headerHostingController?.rootView = headerContent
|
||||
|
||||
let (days, validRanges) = buildItineraryData()
|
||||
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: itineraryItems)
|
||||
|
||||
let (days, validRanges, allItemsForConstraints) = buildItineraryData()
|
||||
controller.reloadData(days: days, travelValidRanges: validRanges, itineraryItems: allItemsForConstraints)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Build Itinerary Data
|
||||
|
||||
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>]) {
|
||||
|
||||
private func buildItineraryData() -> ([ItineraryDayData], [String: ClosedRange<Int>], [ItineraryItem]) {
|
||||
let tripDays = calculateTripDays()
|
||||
var travelValidRanges: [String: ClosedRange<Int>] = [:]
|
||||
|
||||
// Pre-calculate travel segment placements
|
||||
var travelByDay: [Int: TravelSegment] = [:]
|
||||
|
||||
|
||||
// Build travel as semantic items with (day, sortOrder)
|
||||
var travelItems: [ItineraryItem] = []
|
||||
travelItems.reserveCapacity(trip.travelSegments.count)
|
||||
|
||||
func cityFromGameId(_ gameId: String) -> String? {
|
||||
let comps = gameId.components(separatedBy: "-")
|
||||
guard comps.count >= 2 else { return nil }
|
||||
return comps[1]
|
||||
}
|
||||
|
||||
func gamesIn(city: String, day: Int) -> [ItineraryItem] {
|
||||
itineraryItems.filter { item in
|
||||
guard item.day == day else { return false }
|
||||
guard case .game(let gid) = item.kind else { return false }
|
||||
guard let c = cityFromGameId(gid) else { return false }
|
||||
return cityMatches(c, searchCity: city)
|
||||
}
|
||||
}
|
||||
|
||||
for segment in trip.travelSegments {
|
||||
let travelId = stableTravelAnchorId(segment)
|
||||
let fromCity = segment.fromLocation.name
|
||||
let toCity = segment.toLocation.name
|
||||
|
||||
// Calculate valid range
|
||||
// Travel "on day N" appears BEFORE day N's header
|
||||
// So minDay must be AFTER the last game day in departure city
|
||||
let lastGameInFromCity = findLastGameDay(in: fromCity, tripDays: tripDays)
|
||||
let firstGameInToCity = findFirstGameDay(in: toCity, tripDays: tripDays)
|
||||
let minDay = max(lastGameInFromCity + 1, 1) // Day AFTER last game in from city
|
||||
let maxDay = min(firstGameInToCity, tripDays.count) // Can arrive same day as first game
|
||||
let validRange = minDay <= maxDay ? minDay...maxDay : maxDay...maxDay
|
||||
|
||||
|
||||
// VALID RANGE:
|
||||
// - Earliest: day of last from-city game (travel can happen AFTER that game)
|
||||
// - Latest: day of first to-city game (travel can happen BEFORE that game)
|
||||
let lastFromGameDay = findLastGameDay(in: fromCity, tripDays: tripDays)
|
||||
let firstToGameDay = findFirstGameDay(in: toCity, tripDays: tripDays)
|
||||
|
||||
let minDay = max(lastFromGameDay == 0 ? 1 : lastFromGameDay, 1)
|
||||
let maxDay = min(firstToGameDay == 0 ? tripDays.count : firstToGameDay, tripDays.count)
|
||||
|
||||
let validRange = (minDay <= maxDay) ? (minDay...maxDay) : (maxDay...maxDay)
|
||||
travelValidRanges[travelId] = validRange
|
||||
|
||||
// Calculate default day
|
||||
let defaultDay: Int
|
||||
if lastGameInFromCity > 0 && lastGameInFromCity + 1 <= tripDays.count {
|
||||
defaultDay = lastGameInFromCity + 1
|
||||
} else if lastGameInFromCity > 0 {
|
||||
defaultDay = lastGameInFromCity
|
||||
|
||||
// Placement (override if valid)
|
||||
let placement: TravelOverride
|
||||
if let override = travelOverrides[travelId], validRange.contains(override.day) {
|
||||
placement = override
|
||||
} else {
|
||||
defaultDay = 1
|
||||
}
|
||||
// Default day: minDay. Default sortOrder depends on whether it's an edge game day.
|
||||
let day = minDay
|
||||
|
||||
// Use override if valid, otherwise use default
|
||||
if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) {
|
||||
travelByDay[overrideDay] = segment
|
||||
} else {
|
||||
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
|
||||
travelByDay[clampedDefault] = segment
|
||||
// If we're on the last-from-game day, default to AFTER those games.
|
||||
let fromGames = gamesIn(city: fromCity, day: day)
|
||||
let maxFrom = fromGames.map { $0.sortOrder }.max() ?? 0.0
|
||||
var sortOrder = maxFrom + 1.0
|
||||
|
||||
// If we're on the first-to-game day (and it's the same chosen day), default to BEFORE those games.
|
||||
let toGames = gamesIn(city: toCity, day: day)
|
||||
if !toGames.isEmpty {
|
||||
let minTo = toGames.map { $0.sortOrder }.min() ?? 0.0
|
||||
sortOrder = minTo - 1.0
|
||||
}
|
||||
|
||||
placement = TravelOverride(day: day, sortOrder: sortOrder)
|
||||
}
|
||||
|
||||
let travelItem = ItineraryItem(
|
||||
tripId: trip.id,
|
||||
day: placement.day,
|
||||
sortOrder: placement.sortOrder,
|
||||
kind: .travel(
|
||||
TravelInfo(
|
||||
fromCity: fromCity,
|
||||
toCity: toCity,
|
||||
distanceMeters: segment.distanceMeters,
|
||||
durationSeconds: segment.durationSeconds
|
||||
)
|
||||
)
|
||||
)
|
||||
travelItems.append(travelItem)
|
||||
}
|
||||
|
||||
|
||||
// Build day data
|
||||
var days: [ItineraryDayData] = []
|
||||
|
||||
days.reserveCapacity(tripDays.count)
|
||||
|
||||
for (index, dayDate) in tripDays.enumerated() {
|
||||
let dayNum = index + 1
|
||||
let gamesOnDay = gamesOn(date: dayDate)
|
||||
var items: [ItineraryRowItem] = []
|
||||
|
||||
// Travel before this day (travel is stored on the destination day)
|
||||
let travelBefore: TravelSegment? = travelByDay[dayNum]
|
||||
|
||||
// Custom items for this day - filter by day and custom kind, sort by sortOrder
|
||||
// Note: Add button is now embedded in the day header row (not a separate item)
|
||||
|
||||
var rows: [ItineraryRowItem] = []
|
||||
|
||||
// Custom items for this day
|
||||
let customItemsForDay = itineraryItems
|
||||
.filter { $0.day == dayNum && $0.isCustom }
|
||||
.sorted { $0.sortOrder < $1.sortOrder }
|
||||
|
||||
for item in customItemsForDay {
|
||||
items.append(ItineraryRowItem.customItem(item))
|
||||
rows.append(.customItem(item))
|
||||
}
|
||||
|
||||
|
||||
// Travel items for this day (as rows). Ordering comes from sortOrder via controller lookup.
|
||||
let travelsForDay = travelItems
|
||||
.filter { $0.day == dayNum }
|
||||
.sorted { $0.sortOrder < $1.sortOrder }
|
||||
for travel in travelsForDay {
|
||||
// Find the segment matching this travel
|
||||
if let info = travel.travelInfo,
|
||||
let seg = trip.travelSegments.first(where: {
|
||||
$0.fromLocation.name.lowercased() == info.fromCity.lowercased()
|
||||
&& $0.toLocation.name.lowercased() == info.toCity.lowercased()
|
||||
}) {
|
||||
rows.append(.travel(seg, dayNumber: dayNum))
|
||||
}
|
||||
}
|
||||
|
||||
// Sort rows by semantic sortOrder (custom uses its own; travel via travelItems)
|
||||
rows.sort { a, b in
|
||||
func so(_ r: ItineraryRowItem) -> Double {
|
||||
switch r {
|
||||
case .customItem(let it): return it.sortOrder
|
||||
case .travel(let seg, _):
|
||||
let id = stableTravelAnchorId(seg)
|
||||
return (travelOverrides[id]?.sortOrder)
|
||||
?? (travelItems.first(where: { ti in
|
||||
guard case .travel(let inf) = ti.kind else { return false }
|
||||
return inf.fromCity.lowercased() == seg.fromLocation.name.lowercased()
|
||||
&& inf.toCity.lowercased() == seg.toLocation.name.lowercased()
|
||||
})?.sortOrder ?? 0.0)
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
return so(a) < so(b)
|
||||
}
|
||||
|
||||
let dayData = ItineraryDayData(
|
||||
id: dayNum,
|
||||
dayNumber: dayNum,
|
||||
date: dayDate,
|
||||
games: gamesOnDay,
|
||||
items: items,
|
||||
travelBefore: travelBefore
|
||||
items: rows,
|
||||
travelBefore: nil
|
||||
)
|
||||
days.append(dayData)
|
||||
}
|
||||
|
||||
return (days, travelValidRanges)
|
||||
|
||||
return (days, travelValidRanges, itineraryItems + travelItems)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
|
||||
private func calculateTripDays() -> [Date] {
|
||||
let start = trip.startDate
|
||||
let end = trip.endDate
|
||||
|
||||
|
||||
var days: [Date] = []
|
||||
var current = Calendar.current.startOfDay(for: start)
|
||||
let endDay = Calendar.current.startOfDay(for: end)
|
||||
|
||||
|
||||
while current <= endDay {
|
||||
days.append(current)
|
||||
current = Calendar.current.date(byAdding: .day, value: 1, to: current) ?? current
|
||||
}
|
||||
|
||||
|
||||
return days
|
||||
}
|
||||
|
||||
|
||||
private func gamesOn(date: Date) -> [RichGame] {
|
||||
let calendar = Calendar.current
|
||||
return games.filter { calendar.isDate($0.game.dateTime, inSameDayAs: date) }
|
||||
.sorted { $0.game.dateTime < $1.game.dateTime }
|
||||
}
|
||||
|
||||
|
||||
private func stableTravelAnchorId(_ segment: TravelSegment) -> String {
|
||||
"travel:\(segment.fromLocation.name.lowercased())->\(segment.toLocation.name.lowercased())"
|
||||
}
|
||||
|
||||
|
||||
private func findLastGameDay(in city: String, tripDays: [Date]) -> Int {
|
||||
var lastDay = 0
|
||||
|
||||
|
||||
for (index, dayDate) in tripDays.enumerated() {
|
||||
let dayNum = index + 1
|
||||
let gamesOnDay = gamesOn(date: dayDate)
|
||||
@@ -223,7 +292,7 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
}
|
||||
return lastDay
|
||||
}
|
||||
|
||||
|
||||
private func findFirstGameDay(in city: String, tripDays: [Date]) -> Int {
|
||||
for (index, dayDate) in tripDays.enumerated() {
|
||||
let dayNum = index + 1
|
||||
@@ -234,33 +303,33 @@ struct ItineraryTableViewWrapper<HeaderContent: View>: UIViewControllerRepresent
|
||||
}
|
||||
return tripDays.count
|
||||
}
|
||||
|
||||
|
||||
/// Fuzzy city matching - handles "Salt Lake City" vs "Salt Lake" etc.
|
||||
private func cityMatches(_ stadiumCity: String, searchCity: String) -> Bool {
|
||||
let stadiumLower = stadiumCity.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let searchLower = searchCity.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
|
||||
// Exact match
|
||||
if stadiumLower == searchLower {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// One contains the other
|
||||
if stadiumLower.contains(searchLower) || searchLower.contains(stadiumLower) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
// Word-based matching (handles "Salt Lake" matching "Salt Lake City")
|
||||
let stadiumWords = Set(stadiumLower.components(separatedBy: .whitespaces).filter { !$0.isEmpty })
|
||||
let searchWords = Set(searchLower.components(separatedBy: .whitespaces).filter { !$0.isEmpty })
|
||||
|
||||
|
||||
if !searchWords.isEmpty && searchWords.isSubset(of: stadiumWords) {
|
||||
return true
|
||||
}
|
||||
if !stadiumWords.isEmpty && stadiumWords.isSubset(of: searchWords) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ struct TripDetailView: View {
|
||||
@State private var draggedItem: ItineraryItem?
|
||||
@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
|
||||
@State private var travelOverrides: [String: TravelOverride] = [:] // Key: travel ID, Value: day + sortOrder
|
||||
|
||||
private let exportService = ExportService()
|
||||
private let dataProvider = AppDataProvider.shared
|
||||
@@ -81,98 +81,82 @@ struct TripDetailView: View {
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
mainContent
|
||||
.background(Theme.backgroundGradient(colorScheme))
|
||||
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
ShareButton(trip: trip, style: .icon)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
bodyContent
|
||||
}
|
||||
|
||||
Button {
|
||||
if StoreManager.shared.isPro {
|
||||
Task {
|
||||
await exportPDF()
|
||||
}
|
||||
} else {
|
||||
showProPaywall = true
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "doc.fill")
|
||||
if !StoreManager.shared.isPro {
|
||||
ProBadge()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
@ViewBuilder
|
||||
private var bodyContent: some View {
|
||||
mainContent
|
||||
.background(Theme.backgroundGradient(colorScheme))
|
||||
.toolbarBackground(Theme.cardBackground(colorScheme), for: .navigationBar)
|
||||
.toolbar { toolbarContent }
|
||||
.modifier(SheetModifiers(
|
||||
showExportSheet: $showExportSheet,
|
||||
exportURL: exportURL,
|
||||
showProPaywall: $showProPaywall,
|
||||
addItemAnchor: $addItemAnchor,
|
||||
editingItem: $editingItem,
|
||||
tripId: trip.id,
|
||||
saveItineraryItem: saveItineraryItem
|
||||
))
|
||||
.onAppear { checkIfSaved() }
|
||||
.task {
|
||||
await loadGamesIfNeeded()
|
||||
if allowCustomItems {
|
||||
await loadItineraryItems()
|
||||
await setupSubscription()
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showExportSheet) {
|
||||
if let url = exportURL {
|
||||
ShareSheet(items: [url])
|
||||
.onDisappear { subscriptionCancellable?.cancel() }
|
||||
.onChange(of: itineraryItems) { _, newItems in
|
||||
handleItineraryItemsChange(newItems)
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showProPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.sheet(item: $addItemAnchor) { anchor in
|
||||
AddItemSheet(
|
||||
tripId: trip.id,
|
||||
day: anchor.day,
|
||||
existingItem: nil
|
||||
) { item in
|
||||
Task { await saveItineraryItem(item) }
|
||||
.onChange(of: travelOverrides.count) { _, _ in
|
||||
draggedTravelId = nil
|
||||
dropTargetId = nil
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingItem) { item in
|
||||
AddItemSheet(
|
||||
tripId: trip.id,
|
||||
day: item.day,
|
||||
existingItem: item
|
||||
) { updatedItem in
|
||||
Task { await saveItineraryItem(updatedItem) }
|
||||
.overlay {
|
||||
if isExporting { exportProgressOverlay }
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
checkIfSaved()
|
||||
}
|
||||
.task {
|
||||
await loadGamesIfNeeded()
|
||||
if allowCustomItems {
|
||||
await loadItineraryItems()
|
||||
await setupSubscription()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
subscriptionCancellable?.cancel()
|
||||
}
|
||||
.onChange(of: itineraryItems) { _, newItems in
|
||||
// Clear drag state after items update (move completed)
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
// Recalculate routes when custom items change (mappable items affect route)
|
||||
print("🗺️ [MapUpdate] itineraryItems changed, count: \(newItems.count)")
|
||||
for item in newItems {
|
||||
if item.isCustom, let info = item.customInfo, info.isMappable {
|
||||
print("🗺️ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)")
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbarContent: some ToolbarContent {
|
||||
ToolbarItemGroup(placement: .primaryAction) {
|
||||
ShareButton(trip: trip, style: .icon)
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
|
||||
Button {
|
||||
if StoreManager.shared.isPro {
|
||||
Task { await exportPDF() }
|
||||
} else {
|
||||
showProPaywall = true
|
||||
}
|
||||
}
|
||||
Task {
|
||||
updateMapRegion()
|
||||
await fetchDrivingRoutes()
|
||||
} label: {
|
||||
HStack(spacing: 2) {
|
||||
Image(systemName: "doc.fill")
|
||||
if !StoreManager.shared.isPro {
|
||||
ProBadge()
|
||||
}
|
||||
}
|
||||
.foregroundStyle(Theme.warmOrange)
|
||||
}
|
||||
}
|
||||
.onChange(of: travelDayOverrides) { _, _ in
|
||||
// Clear drag state after travel move completed
|
||||
draggedTravelId = nil
|
||||
dropTargetId = nil
|
||||
}
|
||||
.overlay {
|
||||
if isExporting {
|
||||
exportProgressOverlay
|
||||
}
|
||||
|
||||
private func handleItineraryItemsChange(_ newItems: [ItineraryItem]) {
|
||||
draggedItem = nil
|
||||
dropTargetId = nil
|
||||
print("🗺️ [MapUpdate] itineraryItems changed, count: \(newItems.count)")
|
||||
for item in newItems {
|
||||
if item.isCustom, let info = item.customInfo, info.isMappable {
|
||||
print("🗺️ [MapUpdate] Mappable: \(info.title) on day \(item.day), sortOrder: \(item.sortOrder)")
|
||||
}
|
||||
}
|
||||
Task {
|
||||
updateMapRegion()
|
||||
await fetchDrivingRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Main Content
|
||||
@@ -185,7 +169,7 @@ struct TripDetailView: View {
|
||||
trip: trip,
|
||||
games: Array(games.values),
|
||||
itineraryItems: itineraryItems,
|
||||
travelDayOverrides: travelDayOverrides,
|
||||
travelOverrides: travelOverrides,
|
||||
headerContent: {
|
||||
VStack(spacing: 0) {
|
||||
// Hero Map
|
||||
@@ -214,10 +198,10 @@ struct TripDetailView: View {
|
||||
.padding(.bottom, Theme.Spacing.md)
|
||||
}
|
||||
},
|
||||
onTravelMoved: { travelId, newDay in
|
||||
onTravelMoved: { travelId, newDay, newSortOrder in
|
||||
Task { @MainActor in
|
||||
withAnimation {
|
||||
travelDayOverrides[travelId] = newDay
|
||||
travelOverrides[travelId] = TravelOverride(day: newDay, sortOrder: newSortOrder)
|
||||
}
|
||||
await saveTravelDayOverride(travelAnchorId: travelId, displayDay: newDay)
|
||||
}
|
||||
@@ -818,8 +802,9 @@ struct TripDetailView: View {
|
||||
}
|
||||
|
||||
// Check for user override - only use if within valid range
|
||||
if let overrideDay = travelDayOverrides[travelId], validRange.contains(overrideDay) {
|
||||
travelByDay[overrideDay] = segment
|
||||
if let override = travelOverrides[travelId],
|
||||
validRange.contains(override.day) {
|
||||
travelByDay[override.day] = segment
|
||||
} else {
|
||||
// Use default (clamped to valid range)
|
||||
let clampedDefault = max(validRange.lowerBound, min(defaultDay, validRange.upperBound))
|
||||
@@ -1276,16 +1261,18 @@ struct TripDetailView: View {
|
||||
print("✅ [ItineraryItems] Loaded \(items.count) items from CloudKit")
|
||||
itineraryItems = items
|
||||
|
||||
// Extract travel day overrides from travel-type items
|
||||
var overrides: [String: Int] = [:]
|
||||
// Extract travel overrides (day + sortOrder) from travel-type items
|
||||
var overrides: [String: TravelOverride] = [:]
|
||||
|
||||
for item in items where item.isTravel {
|
||||
if let travelInfo = item.travelInfo {
|
||||
let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())"
|
||||
overrides[travelId] = item.day
|
||||
}
|
||||
guard let travelInfo = item.travelInfo else { continue }
|
||||
let travelId = "travel:\(travelInfo.fromCity.lowercased())->\(travelInfo.toCity.lowercased())"
|
||||
|
||||
overrides[travelId] = TravelOverride(day: item.day, sortOrder: item.sortOrder)
|
||||
}
|
||||
travelDayOverrides = overrides
|
||||
print("✅ [TravelOverrides] Extracted \(overrides.count) travel day overrides")
|
||||
|
||||
travelOverrides = overrides
|
||||
print("✅ [TravelOverrides] Extracted \(overrides.count) travel overrides (day + sortOrder)")
|
||||
} catch {
|
||||
print("❌ [ItineraryItems] Failed to load: \(error)")
|
||||
}
|
||||
@@ -1357,21 +1344,35 @@ struct TripDetailView: View {
|
||||
Task { @MainActor in
|
||||
// Check if this is a travel segment being dropped
|
||||
if droppedId.hasPrefix("travel:") {
|
||||
// Validate travel is within valid bounds
|
||||
// Validate travel is within valid bounds (day-level)
|
||||
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
|
||||
|
||||
// Choose a semantic sortOrder for dropping onto a day:
|
||||
// - If this day has games, default to AFTER games (positive)
|
||||
// - If no games, default to 1.0
|
||||
//
|
||||
// You can later support "before games" drops by using a negative sortOrder
|
||||
// when the user drops above the games row.
|
||||
let maxSortOrderOnDay = self.itineraryItems
|
||||
.filter { $0.day == dayNumber }
|
||||
.map { $0.sortOrder }
|
||||
.max() ?? 0.0
|
||||
|
||||
let newSortOrder = max(maxSortOrderOnDay + 1.0, 1.0)
|
||||
|
||||
withAnimation {
|
||||
self.travelDayOverrides[droppedId] = dayNumber
|
||||
self.travelOverrides[droppedId] = TravelOverride(day: dayNumber, sortOrder: newSortOrder)
|
||||
}
|
||||
|
||||
// Persist the override to CloudKit
|
||||
await self.saveTravelDayOverride(travelAnchorId: droppedId, displayDay: dayNumber)
|
||||
|
||||
// Persist to CloudKit as a travel ItineraryItem
|
||||
await self.saveTravelDayOverride(
|
||||
travelAnchorId: droppedId,
|
||||
displayDay: dayNumber
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1901,3 +1902,52 @@ struct TripMapView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Travel Override
|
||||
|
||||
struct TravelOverride: Equatable {
|
||||
let day: Int
|
||||
let sortOrder: Double
|
||||
}
|
||||
|
||||
// MARK: - Sheet Modifiers
|
||||
|
||||
private struct SheetModifiers: ViewModifier {
|
||||
@Binding var showExportSheet: Bool
|
||||
let exportURL: URL?
|
||||
@Binding var showProPaywall: Bool
|
||||
@Binding var addItemAnchor: AddItemAnchor?
|
||||
@Binding var editingItem: ItineraryItem?
|
||||
let tripId: UUID
|
||||
let saveItineraryItem: (ItineraryItem) async -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.sheet(isPresented: $showExportSheet) {
|
||||
if let url = exportURL {
|
||||
ShareSheet(items: [url])
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showProPaywall) {
|
||||
PaywallView()
|
||||
}
|
||||
.sheet(item: $addItemAnchor) { anchor in
|
||||
AddItemSheet(
|
||||
tripId: tripId,
|
||||
day: anchor.day,
|
||||
existingItem: nil
|
||||
) { item in
|
||||
Task { await saveItineraryItem(item) }
|
||||
}
|
||||
}
|
||||
.sheet(item: $editingItem) { item in
|
||||
AddItemSheet(
|
||||
tripId: tripId,
|
||||
day: item.day,
|
||||
existingItem: item
|
||||
) { updatedItem in
|
||||
Task { await saveItineraryItem(updatedItem) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user