// // PDFGenerator.swift // SportsTime // // Generates magazine-style PDF trip itineraries with clean typography and generous whitespace. // import Foundation import PDFKit import UIKit @MainActor final class PDFGenerator { // MARK: - Constants private let pageWidth: CGFloat = 612 // Letter size private let pageHeight: CGFloat = 792 private let margin: CGFloat = 54 // Print-safe margins private let contentWidth: CGFloat = 504 // 612 - 54*2 // Header/footer heights private let headerHeight: CGFloat = 0 // No top header (title in cover only) private let footerHeight: CGFloat = 40 // Room for trip info + page number // Magazine-style spacing private let sectionSpacing: CGFloat = 28 private let itemSpacing: CGFloat = 16 private let daySpacing: CGFloat = 36 // MARK: - Colors private let primaryColor = UIColor(red: 0.12, green: 0.23, blue: 0.54, alpha: 1.0) // Dark blue private let accentColor = UIColor(red: 0.97, green: 0.45, blue: 0.09, alpha: 1.0) // SportsTime orange private let textPrimary = UIColor.black private let textSecondary = UIColor.darkGray private let textMuted = UIColor.gray // Sport-specific accent colors private func sportAccentColor(for sport: Sport) -> UIColor { switch sport { case .mlb: return UIColor(red: 0.76, green: 0.09, blue: 0.16, alpha: 1.0) // Baseball red case .nba: return UIColor(red: 0.91, green: 0.33, blue: 0.11, alpha: 1.0) // Basketball orange case .nhl: return UIColor(red: 0.0, green: 0.32, blue: 0.58, alpha: 1.0) // Hockey blue case .nfl: return UIColor(red: 0.0, green: 0.21, blue: 0.38, alpha: 1.0) // Football navy case .mls: return UIColor(red: 0.0, green: 0.5, blue: 0.35, alpha: 1.0) // Soccer green case .wnba: return UIColor(red: 0.98, green: 0.33, blue: 0.14, alpha: 1.0) // WNBA orange case .nwsl: return UIColor(red: 0.0, green: 0.45, blue: 0.65, alpha: 1.0) // NWSL blue } } // MARK: - Trip Info (for footer) private var currentTrip: Trip? private var totalPages: Int = 1 // MARK: - Generate PDF func generatePDF( for trip: Trip, games: [String: RichGame], assets: PDFAssetPrefetcher.PrefetchedAssets? = nil, itineraryItems: [ItineraryItem]? = nil ) async throws -> Data { // Store trip for footer access currentTrip = trip // Calculate total pages for footer totalPages = calculateTotalPages(trip: trip, games: games, itineraryItems: itineraryItems) let pdfRenderer = UIGraphicsPDFRenderer( bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight) ) let data = pdfRenderer.pdfData { context in // Page 1: Cover with hero map context.beginPage() drawCoverPage(context: context, trip: trip, assets: assets) // Pages 2+: Day-by-Day Itinerary (magazine-style continuous flow) drawItineraryPages(context: context, trip: trip, games: games, assets: assets, itineraryItems: itineraryItems) } currentTrip = nil return data } /// Estimate total pages for footer display private func calculateTotalPages(trip: Trip, games: [String: RichGame], itineraryItems: [ItineraryItem]?) -> Int { var pages = 1 // Cover page let usableHeight = pageHeight - margin - footerHeight - 50 // Content area per page var currentPageUsed: CGFloat = 0 let allTripDays = trip.itineraryDays() if let items = itineraryItems, !items.isEmpty { let groupedItems = Dictionary(grouping: items) { $0.day } // Iterate over ALL trip days, not just days with items for day in allTripDays { let dayItems = groupedItems[day.dayNumber] ?? [] let dayHeight = estimateItemsDayHeight(items: dayItems, games: games) if currentPageUsed + dayHeight > usableHeight { pages += 1 currentPageUsed = dayHeight } else { currentPageUsed += dayHeight + daySpacing } } } else { for day in trip.itineraryDays() { let dayHeight = estimateDayHeight(day: day, games: games) if currentPageUsed + dayHeight > usableHeight { pages += 1 currentPageUsed = dayHeight } else { currentPageUsed += dayHeight + daySpacing } } } // Add one more page for any remaining content if currentPageUsed > 0 { pages += 1 } return max(2, pages) // At least cover + 1 itinerary page } // MARK: - Cover Page private func drawCoverPage( context: UIGraphicsPDFRendererContext, trip: Trip, assets: PDFAssetPrefetcher.PrefetchedAssets? ) { var y: CGFloat = margin // Hero route map (full width, large) if let routeMap = assets?.routeMap { let mapHeight: CGFloat = 320 let mapRect = CGRect(x: margin, y: y, width: contentWidth, height: mapHeight) // Draw map with rounded corners let path = UIBezierPath(roundedRect: mapRect, cornerRadius: 16) context.cgContext.saveGState() path.addClip() routeMap.draw(in: mapRect) context.cgContext.restoreGState() // Subtle border UIColor.lightGray.withAlphaComponent(0.5).setStroke() path.lineWidth = 1 path.stroke() y += mapHeight + sectionSpacing } else { y = 140 } // Accent bar let barRect = CGRect(x: margin, y: y, width: 80, height: 4) let barPath = UIBezierPath(roundedRect: barRect, cornerRadius: 2) accentColor.setFill() barPath.fill() y += 20 // Trip name (large, bold) let titleFont = UIFont.systemFont(ofSize: 34, weight: .bold) let titleAttributes: [NSAttributedString.Key: Any] = [ .font: titleFont, .foregroundColor: textPrimary ] let titleRect = CGRect(x: margin, y: y, width: contentWidth, height: 45) (trip.displayName as NSString).draw(in: titleRect, withAttributes: titleAttributes) y += 50 // Date range (formatted nicely) let dateRangeText = formatCoverDateRange(trip: trip) let dateAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 18, weight: .regular), .foregroundColor: textSecondary ] (dateRangeText as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: dateAttributes) y += 36 // Quick stats with bullet separators let statsText = "\(trip.stops.count) Cities • \(trip.totalGames) Games • \(formatMiles(trip.totalDistanceMiles))" let statsAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 15, weight: .medium), .foregroundColor: primaryColor ] (statsText as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: statsAttributes) y += 28 // Driving summary let drivingText = "\(trip.tripDuration) Days • \(trip.formattedTotalDriving) driving" let drivingAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 14), .foregroundColor: textMuted ] (drivingText as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: drivingAttributes) y += sectionSpacing + 12 // Route overview (compact city list) drawCoverSectionHeader("Route", at: &y) let cityFont = UIFont.systemFont(ofSize: 13) let cityAttributes: [NSAttributedString.Key: Any] = [ .font: cityFont, .foregroundColor: textSecondary ] // Draw cities in a flowing layout var cityLine = "" for (index, city) in trip.cities.enumerated() { let separator = index == 0 ? "" : " → " let testLine = cityLine + separator + city // Check if we need to wrap let testSize = (testLine as NSString).size(withAttributes: cityAttributes) if testSize.width > contentWidth - 20 && !cityLine.isEmpty { (cityLine as NSString).draw(at: CGPoint(x: margin + 12, y: y), withAttributes: cityAttributes) y += 22 cityLine = city } else { cityLine = testLine } } // Draw remaining if !cityLine.isEmpty { (cityLine as NSString).draw(at: CGPoint(x: margin + 12, y: y), withAttributes: cityAttributes) y += 22 } // Footer drawFooter(context: context, pageNumber: 1) } /// Format date range for cover: "Jan 18 – Jan 25, 2026" private func formatCoverDateRange(trip: Trip) -> String { let formatter = DateFormatter() // Get first and last dates guard let firstStop = trip.stops.first, let lastStop = trip.stops.last else { return trip.formattedDateRange } let startDate = firstStop.arrivalDate let endDate = lastStop.departureDate // Same year? let startYear = Calendar.current.component(.year, from: startDate) let endYear = Calendar.current.component(.year, from: endDate) if startYear == endYear { formatter.dateFormat = "MMM d" let startStr = formatter.string(from: startDate) formatter.dateFormat = "MMM d, yyyy" let endStr = formatter.string(from: endDate) return "\(startStr) – \(endStr)" } else { formatter.dateFormat = "MMM d, yyyy" return "\(formatter.string(from: startDate)) – \(formatter.string(from: endDate))" } } /// Format miles: "1,234 mi" private func formatMiles(_ miles: Double) -> String { let formatter = NumberFormatter() formatter.numberStyle = .decimal formatter.maximumFractionDigits = 0 let formatted = formatter.string(from: NSNumber(value: miles)) ?? "\(Int(miles))" return "\(formatted) mi" } private func drawCoverSectionHeader(_ title: String, at y: inout CGFloat) { let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 12, weight: .semibold), .foregroundColor: textMuted ] (title.uppercased() as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: attributes) y += 20 } // MARK: - Enhanced Footer private func drawFooter(context: UIGraphicsPDFRendererContext, pageNumber: Int) { let footerY = pageHeight - footerHeight // Separator line let linePath = UIBezierPath() linePath.move(to: CGPoint(x: margin, y: footerY)) linePath.addLine(to: CGPoint(x: pageWidth - margin, y: footerY)) UIColor.lightGray.withAlphaComponent(0.3).setStroke() linePath.lineWidth = 0.5 linePath.stroke() let textY = footerY + 12 // Left: SportsTime branding let brandAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 9, weight: .semibold), .foregroundColor: accentColor ] ("SportsTime" as NSString).draw(at: CGPoint(x: margin, y: textY), withAttributes: brandAttributes) // Center: Trip name + dates if let trip = currentTrip { let centerText = "\(trip.displayName) • \(formatCoverDateRange(trip: trip))" let centerAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 9), .foregroundColor: textMuted ] let centerSize = (centerText as NSString).size(withAttributes: centerAttributes) let centerX = (pageWidth - centerSize.width) / 2 (centerText as NSString).draw(at: CGPoint(x: centerX, y: textY), withAttributes: centerAttributes) } // Right: Page number let pageText = "Page \(pageNumber)" let pageAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 9), .foregroundColor: textMuted ] let pageSize = (pageText as NSString).size(withAttributes: pageAttributes) (pageText as NSString).draw( at: CGPoint(x: pageWidth - margin - pageSize.width, y: textY), withAttributes: pageAttributes ) } // MARK: - Itinerary Pages private func drawItineraryPages( context: UIGraphicsPDFRendererContext, trip: Trip, games: [String: RichGame], assets: PDFAssetPrefetcher.PrefetchedAssets?, itineraryItems: [ItineraryItem]? ) { var pageNumber = 2 var y: CGFloat = margin var isFirstDayOnPage = true context.beginPage() drawItineraryPageHeader(at: &y) // Usable content height (accounting for footer) let usableHeight = pageHeight - footerHeight - 20 // Get all trip days to ensure we render every day let allTripDays = trip.itineraryDays() // If we have custom itinerary items, use their order; otherwise fall back to derived order if let items = itineraryItems, !items.isEmpty { let groupedItems = Dictionary(grouping: items) { $0.day } // Iterate over ALL trip days, not just days with items for day in allTripDays { let dayNumber = day.dayNumber let dayItems = groupedItems[dayNumber]?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? [] let estimatedHeight = estimateItemsDayHeight(items: dayItems, games: games) if y + estimatedHeight > usableHeight { drawFooter(context: context, pageNumber: pageNumber) pageNumber += 1 context.beginPage() y = margin isFirstDayOnPage = true } if !isFirstDayOnPage { y += daySpacing } y = drawDayWithItems( context: context, dayNumber: dayNumber, date: day.date, items: dayItems, games: games, assets: assets, y: y ) isFirstDayOnPage = false } } else { for day in trip.itineraryDays() { let estimatedHeight = estimateDayHeight(day: day, games: games) if y + estimatedHeight > usableHeight { drawFooter(context: context, pageNumber: pageNumber) pageNumber += 1 context.beginPage() y = margin isFirstDayOnPage = true } if !isFirstDayOnPage { y += daySpacing } y = drawDay( context: context, day: day, games: games, assets: assets, y: y ) isFirstDayOnPage = false } } drawFooter(context: context, pageNumber: pageNumber) } private func drawItineraryPageHeader(at y: inout CGFloat) { let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 11, weight: .semibold), .foregroundColor: textMuted ] ("ITINERARY" as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: attributes) y += sectionSpacing } /// Estimate height for a day rendered from ItineraryItems private func estimateItemsDayHeight(items: [ItineraryItem], games: [String: RichGame]) -> CGFloat { var height: CGFloat = 50 // Day header for item in items { switch item.kind { case .game: height += 44 + itemSpacing // Simplified game entry case .travel: height += 36 + itemSpacing // Travel segment case .custom: height += 40 + itemSpacing // Custom item } } return height } /// Draw a day using ItineraryItems for order (magazine-style) 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: "Day 1 • Sat, Jan 18" let dayHeaderAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 16, weight: .bold), .foregroundColor: textPrimary ] var dayHeaderText = "Day \(dayNumber)" if let date = date { let formatter = DateFormatter() formatter.dateFormat = "EEE, MMM d" // "Sat, Jan 18" dayHeaderText += " • \(formatter.string(from: date))" } (dayHeaderText as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: dayHeaderAttributes) currentY += 28 // Location subtitle (if we can determine it) let primaryCity = determinePrimaryCity(from: items, games: games) if let city = primaryCity { let locationAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 12), .foregroundColor: textMuted ] (city as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: locationAttributes) currentY += 22 } currentY += 8 // Space before items // Draw items in sortOrder var hasContent = false for item in items { switch item.kind { case .game(let gameId, _): if let richGame = games[gameId] { currentY = drawGameEntry(richGame: richGame, y: currentY) currentY += itemSpacing hasContent = true } case .travel(let travelInfo): currentY = drawTravelEntry(info: travelInfo, y: currentY) currentY += itemSpacing hasContent = true case .custom(let customInfo): currentY = drawCustomEntry(info: customInfo, y: currentY) currentY += itemSpacing hasContent = true } } // Rest day message if no content if !hasContent { let restAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.italicSystemFont(ofSize: 13), .foregroundColor: textMuted ] ("Rest Day – Explore the city!" as NSString).draw( at: CGPoint(x: margin + 12, y: currentY), withAttributes: restAttributes ) currentY += 28 } return currentY } /// Determine primary city from items private func determinePrimaryCity(from items: [ItineraryItem], games: [String: RichGame]) -> String? { for item in items { switch item.kind { case .game(let gameId, let city): // Prefer stadium city if available if let richGame = games[gameId] { return "\(richGame.stadium.city), \(richGame.stadium.state)" } return city case .travel(let info): return info.toCity case .custom: continue // Skip custom items for city detection } } return nil } // MARK: - Magazine-Style Item Drawing /// Draw a simplified game entry: "Lakers vs. Celtics • 7:00 PM" private func drawGameEntry(richGame: RichGame, y: CGFloat) -> CGFloat { let currentY = y // Sport color accent (left border) let accentBarRect = CGRect(x: margin, y: currentY, width: 3, height: 36) let sportColor = sportAccentColor(for: richGame.game.sport) sportColor.setFill() UIBezierPath(roundedRect: accentBarRect, cornerRadius: 1.5).fill() // Teams: "Lakers vs. Celtics" let teamsText = "\(richGame.awayTeam.name) vs. \(richGame.homeTeam.name)" let teamsAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 14, weight: .semibold), .foregroundColor: textPrimary ] (teamsText as NSString).draw(at: CGPoint(x: margin + 14, y: currentY), withAttributes: teamsAttributes) // Time: "7:00 PM EDT" (stadium local time with timezone indicator) let timeText = richGame.localGameTime let timeAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 13), .foregroundColor: textSecondary ] (timeText as NSString).draw(at: CGPoint(x: margin + 14, y: currentY + 20), withAttributes: timeAttributes) // Venue name (subtle, after time) let venueText = " • \(richGame.stadium.name)" let venueAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 13), .foregroundColor: textMuted ] let timeSize = (timeText as NSString).size(withAttributes: timeAttributes) (venueText as NSString).draw( at: CGPoint(x: margin + 14 + timeSize.width, y: currentY + 20), withAttributes: venueAttributes ) return currentY + 40 } /// Draw a travel entry: "→ 3h 45m • 220 mi to Phoenix" private func drawTravelEntry(info: TravelInfo, y: CGFloat) -> CGFloat { let currentY = y // Arrow indicator let arrowAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 12, weight: .medium), .foregroundColor: textMuted ] ("→" as NSString).draw(at: CGPoint(x: margin + 4, y: currentY + 2), withAttributes: arrowAttributes) // Build travel text: "3h 45m • 220 mi to Phoenix" var travelParts: [String] = [] if !info.formattedDuration.isEmpty { travelParts.append(info.formattedDuration) } if !info.formattedDistance.isEmpty { // Convert to miles format travelParts.append(info.formattedDistance) } travelParts.append("to \(info.toCity)") let travelText = travelParts.joined(separator: " • ") let travelAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 12), .foregroundColor: textSecondary ] (travelText as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 2), withAttributes: travelAttributes) return currentY + 28 } /// Draw a custom item entry: "🍽️ Lunch at Joe's Diner • 123 Main St" private func drawCustomEntry(info: CustomInfo, y: CGFloat) -> CGFloat { let currentY = y // Icon let iconAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 14) ] (info.icon as NSString).draw(at: CGPoint(x: margin + 2, y: currentY), withAttributes: iconAttributes) // Title let titleAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 13, weight: .medium), .foregroundColor: textPrimary ] (info.title as NSString).draw(at: CGPoint(x: margin + 24, y: currentY), withAttributes: titleAttributes) // Address (if present) if let address = info.address { let addressAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 11), .foregroundColor: textMuted ] (address as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 18), withAttributes: addressAttributes) return currentY + 36 } return currentY + 22 } /// Draw a lodging entry: "🏨 Marriott Downtown • 123 Main St" private func drawLodgingEntry(name: String, address: String?, y: CGFloat) -> CGFloat { let currentY = y // Hotel icon let iconAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 14) ] ("🏨" as NSString).draw(at: CGPoint(x: margin + 2, y: currentY), withAttributes: iconAttributes) // Name let nameAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 13, weight: .medium), .foregroundColor: textPrimary ] (name as NSString).draw(at: CGPoint(x: margin + 24, y: currentY), withAttributes: nameAttributes) // Address if let address = address { let addressAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 11), .foregroundColor: textMuted ] (address as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 18), withAttributes: addressAttributes) return currentY + 36 } return currentY + 22 } private func estimateDayHeight(day: ItineraryDay, games: [String: RichGame]) -> CGFloat { var height: CGFloat = 50 // Day header + city // Games (simplified) height += CGFloat(day.gameIds.count) * (44 + itemSpacing) // Travel if day.hasTravelSegment { height += 36 + itemSpacing } // Rest day if day.isRestDay && day.gameIds.isEmpty { height += 28 } return height } /// Draw a day using derived order (magazine-style) private func drawDay( context: UIGraphicsPDFRendererContext, day: ItineraryDay, games: [String: RichGame], assets: PDFAssetPrefetcher.PrefetchedAssets?, y: CGFloat ) -> CGFloat { var currentY = y // Day header: "Day 1 • Sat, Jan 18" let dayHeaderAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 16, weight: .bold), .foregroundColor: textPrimary ] let formatter = DateFormatter() formatter.dateFormat = "EEE, MMM d" // "Sat, Jan 18" let dayHeaderText = "Day \(day.dayNumber) • \(formatter.string(from: day.date))" (dayHeaderText as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: dayHeaderAttributes) currentY += 28 // Location subtitle if let city = day.primaryCity { let locationAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 12), .foregroundColor: textMuted ] (city as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: locationAttributes) currentY += 22 } currentY += 8 // Space before items // Games for gameId in day.gameIds { if let richGame = games[gameId] { currentY = drawGameEntry(richGame: richGame, y: currentY) currentY += itemSpacing } } // Travel segments for segment in day.travelSegments { currentY = drawTravelSegmentEntry(segment: segment, y: currentY) currentY += itemSpacing } // Rest day if day.isRestDay && day.gameIds.isEmpty { let restAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.italicSystemFont(ofSize: 13), .foregroundColor: textMuted ] ("Rest Day – Explore the city!" as NSString).draw( at: CGPoint(x: margin + 12, y: currentY), withAttributes: restAttributes ) currentY += 28 } return currentY } /// Draw a travel segment entry (from TravelSegment, not TravelInfo) private func drawTravelSegmentEntry(segment: TravelSegment, y: CGFloat) -> CGFloat { let currentY = y // Arrow indicator let arrowAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 12, weight: .medium), .foregroundColor: textMuted ] ("→" as NSString).draw(at: CGPoint(x: margin + 4, y: currentY + 2), withAttributes: arrowAttributes) // Build travel text let travelText = "\(segment.formattedDuration) • \(segment.formattedDistance) to \(segment.toLocation.name)" let travelAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 12), .foregroundColor: textSecondary ] (travelText as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 2), withAttributes: travelAttributes) return currentY + 28 } } // MARK: - UIColor Extension extension UIColor { convenience init?(hex: String) { var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines) hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "") guard hexSanitized.count == 6 else { return nil } var rgb: UInt64 = 0 Scanner(string: hexSanitized).scanHexInt64(&rgb) let red = CGFloat((rgb & 0xFF0000) >> 16) / 255.0 let green = CGFloat((rgb & 0x00FF00) >> 8) / 255.0 let blue = CGFloat(rgb & 0x0000FF) / 255.0 self.init(red: red, green: green, blue: blue, alpha: 1.0) } } // MARK: - Export Service @MainActor final class ExportService { private let pdfGenerator = PDFGenerator() 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: (@Sendable (PDFAssetPrefetcher.PrefetchProgress) async -> Void)? = nil ) async throws -> URL { // Prefetch all assets let assets = await assetPrefetcher.prefetchAssets( for: trip, games: games, progressCallback: progressCallback ) // 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" let url = FileManager.default.temporaryDirectory.appendingPathComponent(fileName) try data.write(to: url) return url } /// Quick export without prefetching (basic PDF) 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) try data.write(to: url) return url } func shareTrip(_ trip: Trip) -> URL? { let baseURL = "sportstime://trip/" return URL(string: baseURL + trip.id.uuidString) } }