From e79f29d9c7557db2aa377e3d721839d7b9181351 Mon Sep 17 00:00:00 2001 From: Trey t Date: Sat, 17 Jan 2026 22:07:06 -0600 Subject: [PATCH] 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 --- SportsTime/Export/PDFGenerator.swift | 341 ++++++++++++++++++++++++--- 1 file changed, 313 insertions(+), 28 deletions(-) diff --git a/SportsTime/Export/PDFGenerator.swift b/SportsTime/Export/PDFGenerator.swift index d9c198e..3ed1661 100644 --- a/SportsTime/Export/PDFGenerator.swift +++ b/SportsTime/Export/PDFGenerator.swift @@ -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)