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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user