feat(export): respect custom itinerary order in PDF export

Update PDFGenerator to accept optional ItineraryItem array and render
items in user-specified sortOrder within each day. Adds support for:
- Custom items with icon, title, time, and address
- Travel segments from ItineraryItem (not just TravelSegment)
- Fallback to derived order when no itinerary items provided

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-01-17 22:07:06 -06:00
parent 9c40721af0
commit e79f29d9c7

View File

@@ -29,7 +29,8 @@ final class PDFGenerator {
func generatePDF(
for trip: Trip,
games: [String: RichGame],
assets: PDFAssetPrefetcher.PrefetchedAssets? = nil
assets: PDFAssetPrefetcher.PrefetchedAssets? = nil,
itineraryItems: [ItineraryItem]? = nil
) async throws -> Data {
let pdfRenderer = UIGraphicsPDFRenderer(
bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)
@@ -45,7 +46,7 @@ final class PDFGenerator {
drawRouteOverviewPage(context: context, trip: trip, assets: assets)
// Day-by-Day Itinerary
drawItineraryPages(context: context, trip: trip, games: games, assets: assets)
drawItineraryPages(context: context, trip: trip, games: games, assets: assets, itineraryItems: itineraryItems)
// City Spotlight pages
drawCitySpotlightPages(context: context, trip: trip, games: games, assets: assets)
@@ -245,7 +246,8 @@ final class PDFGenerator {
context: UIGraphicsPDFRendererContext,
trip: Trip,
games: [String: RichGame],
assets: PDFAssetPrefetcher.PrefetchedAssets?
assets: PDFAssetPrefetcher.PrefetchedAssets?,
itineraryItems: [ItineraryItem]?
) {
var pageNumber = 3
var y: CGFloat = margin
@@ -254,35 +256,298 @@ final class PDFGenerator {
context.beginPage()
drawPageHeader("Day-by-Day Itinerary", at: &y)
for day in trip.itineraryDays() {
// Check if we need a new page
let estimatedHeight = estimateDayHeight(day: day, games: games)
if y + estimatedHeight > pageHeight - 80 {
drawFooter(context: context, pageNumber: pageNumber)
pageNumber += 1
context.beginPage()
y = margin
isFirstDayOnPage = true
// If we have custom itinerary items, use their order; otherwise fall back to derived order
if let items = itineraryItems, !items.isEmpty {
// Group items by day, sorted by sortOrder
let groupedItems = Dictionary(grouping: items) { $0.day }
let sortedDays = groupedItems.keys.sorted()
for dayNumber in sortedDays {
guard let dayItems = groupedItems[dayNumber]?.sorted(by: { $0.sortOrder < $1.sortOrder }) else { continue }
// Check if we need a new page
let estimatedHeight = estimateItemsDayHeight(items: dayItems, games: games)
if y + estimatedHeight > pageHeight - 80 {
drawFooter(context: context, pageNumber: pageNumber)
pageNumber += 1
context.beginPage()
y = margin
isFirstDayOnPage = true
}
if !isFirstDayOnPage {
y += 15 // Space between days
}
// Find corresponding itinerary day for date info
let itineraryDays = trip.itineraryDays()
let itineraryDay = itineraryDays.first { $0.dayNumber == dayNumber }
y = drawDayWithItems(
context: context,
dayNumber: dayNumber,
date: itineraryDay?.date,
items: dayItems,
games: games,
assets: assets,
y: y
)
isFirstDayOnPage = false
}
} else {
// Fall back to derived order from trip
for day in trip.itineraryDays() {
// Check if we need a new page
let estimatedHeight = estimateDayHeight(day: day, games: games)
if y + estimatedHeight > pageHeight - 80 {
drawFooter(context: context, pageNumber: pageNumber)
pageNumber += 1
context.beginPage()
y = margin
isFirstDayOnPage = true
}
if !isFirstDayOnPage {
y += 15 // Space between days
if !isFirstDayOnPage {
y += 15 // Space between days
}
y = drawDay(
context: context,
day: day,
games: games,
assets: assets,
y: y
)
isFirstDayOnPage = false
}
y = drawDay(
context: context,
day: day,
games: games,
assets: assets,
y: y
)
isFirstDayOnPage = false
}
drawFooter(context: context, pageNumber: pageNumber)
}
/// Estimate height for a day rendered from ItineraryItems
private func estimateItemsDayHeight(items: [ItineraryItem], games: [String: RichGame]) -> CGFloat {
var height: CGFloat = 60 // Day header + city
for item in items {
switch item.kind {
case .game:
height += 80 // Game card
case .travel:
height += 50 // Travel segment
case .custom:
height += 55 // Custom item card
}
}
return height
}
/// Draw a day using ItineraryItems for order
private func drawDayWithItems(
context: UIGraphicsPDFRendererContext,
dayNumber: Int,
date: Date?,
items: [ItineraryItem],
games: [String: RichGame],
assets: PDFAssetPrefetcher.PrefetchedAssets?,
y: CGFloat
) -> CGFloat {
var currentY = y
// Day header with accent background
let headerRect = CGRect(x: margin, y: currentY, width: contentWidth, height: 32)
let headerPath = UIBezierPath(roundedRect: headerRect, cornerRadius: 6)
primaryColor.withAlphaComponent(0.1).setFill()
headerPath.fill()
let dayHeaderAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 16),
.foregroundColor: primaryColor
]
let dateText: String
if let date = date {
let formatter = DateFormatter()
formatter.dateFormat = "EEEE, MMM d"
dateText = formatter.string(from: date)
} else {
dateText = ""
}
let dayHeader = "Day \(dayNumber)" + (dateText.isEmpty ? "" : " - \(dateText)")
(dayHeader as NSString).draw(
at: CGPoint(x: margin + 12, y: currentY + 7),
withAttributes: dayHeaderAttributes
)
currentY += 40
// Determine primary city from first game or travel
var primaryCity: String?
for item in items {
switch item.kind {
case .game(let gameId):
if let richGame = games[gameId] {
primaryCity = richGame.stadium.city
break
}
case .travel(let info):
primaryCity = info.toCity
break
case .custom(let info):
if let address = info.address {
primaryCity = address
break
}
}
if primaryCity != nil { break }
}
if let city = primaryCity {
let cityAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 13),
.foregroundColor: UIColor.darkGray
]
let cityText = "Location: \(city)"
(cityText as NSString).draw(at: CGPoint(x: margin + 12, y: currentY), withAttributes: cityAttributes)
currentY += 25
}
// Draw items in sortOrder
var hasContent = false
for item in items {
switch item.kind {
case .game(let gameId):
if let richGame = games[gameId] {
currentY = drawGameCard(
context: context,
richGame: richGame,
assets: assets,
y: currentY
)
currentY += 10
hasContent = true
}
case .travel(let travelInfo):
currentY = drawTravelItem(info: travelInfo, y: currentY)
hasContent = true
case .custom(let customInfo):
currentY = drawCustomItem(info: customInfo, y: currentY)
hasContent = true
}
}
// Rest day message if no content
if !hasContent {
let restAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.italicSystemFont(ofSize: 13),
.foregroundColor: UIColor.gray
]
let restRect = CGRect(x: margin + 12, y: currentY, width: contentWidth - 12, height: 25)
("Rest Day - Explore the city!" as NSString).draw(in: restRect, withAttributes: restAttributes)
currentY += 30
}
// Day separator line
drawHorizontalLine(at: currentY + 5, alpha: 0.3)
currentY += 15
return currentY
}
/// Draw a travel item from ItineraryItem
private func drawTravelItem(info: TravelInfo, y: CGFloat) -> CGFloat {
let currentY = y
let travelRect = CGRect(x: margin + 10, y: currentY, width: contentWidth - 20, height: 35)
let travelPath = UIBezierPath(roundedRect: travelRect, cornerRadius: 6)
UIColor.systemGray6.setFill()
travelPath.fill()
// Car icon placeholder
let iconAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor.gray
]
(">" as NSString).draw(at: CGPoint(x: margin + 20, y: currentY + 10), withAttributes: iconAttributes)
// Travel text
let travelAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor.darkGray
]
var travelText = "Drive: \(info.fromCity)\(info.toCity)"
if !info.formattedDistance.isEmpty || !info.formattedDuration.isEmpty {
let details = [info.formattedDistance, info.formattedDuration].filter { !$0.isEmpty }.joined(separator: " | ")
if !details.isEmpty {
travelText += " | \(details)"
}
}
(travelText as NSString).draw(
at: CGPoint(x: margin + 40, y: currentY + 10),
withAttributes: travelAttributes
)
return currentY + 45
}
/// Draw a custom item card
private func drawCustomItem(info: CustomInfo, y: CGFloat) -> CGFloat {
let currentY = y
// Card background
let cardRect = CGRect(x: margin + 10, y: currentY, width: contentWidth - 20, height: 45)
let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 8)
accentColor.withAlphaComponent(0.08).setFill()
cardPath.fill()
// Card border
accentColor.withAlphaComponent(0.3).setStroke()
cardPath.lineWidth = 1
cardPath.stroke()
// Icon
let iconAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 16),
.foregroundColor: accentColor
]
(info.icon as NSString).draw(at: CGPoint(x: margin + 20, y: currentY + 12), withAttributes: iconAttributes)
// Title
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 13),
.foregroundColor: UIColor.black
]
(info.title as NSString).draw(at: CGPoint(x: margin + 45, y: currentY + 8), withAttributes: titleAttributes)
// Time and address
var detailParts: [String] = []
if let time = info.time {
let formatter = DateFormatter()
formatter.dateFormat = "h:mm a"
detailParts.append(formatter.string(from: time))
}
if let address = info.address {
detailParts.append(address)
}
if !detailParts.isEmpty {
let detailAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 11),
.foregroundColor: UIColor.gray
]
let detailText = detailParts.joined(separator: " | ")
(detailText as NSString).draw(at: CGPoint(x: margin + 45, y: currentY + 26), withAttributes: detailAttributes)
}
return currentY + 55
}
private func estimateDayHeight(day: ItineraryDay, games: [String: RichGame]) -> CGFloat {
var height: CGFloat = 60 // Day header + city
@@ -894,9 +1159,15 @@ final class ExportService {
private let assetPrefetcher = PDFAssetPrefetcher()
/// Export trip to PDF with full prefetched assets
/// - Parameters:
/// - trip: The trip to export
/// - games: Dictionary of game IDs to RichGame objects
/// - itineraryItems: Optional custom itinerary items for user-specified ordering
/// - progressCallback: Optional callback for prefetch progress
func exportToPDF(
trip: Trip,
games: [String: RichGame],
itineraryItems: [ItineraryItem]? = nil,
progressCallback: ((PDFAssetPrefetcher.PrefetchProgress) async -> Void)? = nil
) async throws -> URL {
// Prefetch all assets
@@ -906,8 +1177,13 @@ final class ExportService {
progressCallback: progressCallback
)
// Generate PDF with assets
let data = try await pdfGenerator.generatePDF(for: trip, games: games, assets: assets)
// Generate PDF with assets and custom itinerary order
let data = try await pdfGenerator.generatePDF(
for: trip,
games: games,
assets: assets,
itineraryItems: itineraryItems
)
// Save to temp file
let fileName = "\(trip.name.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).pdf"
@@ -918,8 +1194,17 @@ final class ExportService {
}
/// Quick export without prefetching (basic PDF)
func exportToPDFBasic(trip: Trip, games: [String: RichGame]) async throws -> URL {
let data = try await pdfGenerator.generatePDF(for: trip, games: games, assets: nil)
func exportToPDFBasic(
trip: Trip,
games: [String: RichGame],
itineraryItems: [ItineraryItem]? = nil
) async throws -> URL {
let data = try await pdfGenerator.generatePDF(
for: trip,
games: games,
assets: nil,
itineraryItems: itineraryItems
)
let fileName = "\(trip.name.replacingOccurrences(of: " ", with: "_"))_\(Date().timeIntervalSince1970).pdf"
let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName)