// // PDFGenerator.swift // SportsTime // // Generates beautiful PDF trip itineraries with maps, photos, and attractions. // 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 = 50 private let contentWidth: CGFloat = 512 // 612 - 50*2 // 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) // Orange // MARK: - Generate PDF func generatePDF( for trip: Trip, games: [String: RichGame], assets: PDFAssetPrefetcher.PrefetchedAssets? = nil ) async throws -> Data { let pdfRenderer = UIGraphicsPDFRenderer( bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight) ) let data = pdfRenderer.pdfData { context in // Page 1: Cover context.beginPage() drawCoverPage(context: context, trip: trip, assets: assets) // Page 2: Route Overview context.beginPage() drawRouteOverviewPage(context: context, trip: trip, assets: assets) // Day-by-Day Itinerary drawItineraryPages(context: context, trip: trip, games: games, assets: assets) // City Spotlight pages drawCitySpotlightPages(context: context, trip: trip, games: games, assets: assets) // Final page: Summary context.beginPage() drawSummaryPage(context: context, trip: trip, games: games) } return data } // MARK: - Cover Page private func drawCoverPage( context: UIGraphicsPDFRendererContext, trip: Trip, assets: PDFAssetPrefetcher.PrefetchedAssets? ) { var y: CGFloat = margin // Hero route map (if available) if let routeMap = assets?.routeMap { let mapHeight: CGFloat = 350 let mapRect = CGRect(x: margin, y: y, width: contentWidth, height: mapHeight) // Draw map with rounded corners let path = UIBezierPath(roundedRect: mapRect, cornerRadius: 12) context.cgContext.saveGState() path.addClip() routeMap.draw(in: mapRect) context.cgContext.restoreGState() // Border UIColor.lightGray.setStroke() path.lineWidth = 1 path.stroke() y += mapHeight + 30 } else { // No map - add extra spacing y = 180 } // Color accent bar let barRect = CGRect(x: margin, y: y, width: contentWidth, height: 6) let barPath = UIBezierPath(roundedRect: barRect, cornerRadius: 3) primaryColor.setFill() barPath.fill() y += 25 // Trip name let titleAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 32), .foregroundColor: UIColor.black ] let titleRect = CGRect(x: margin, y: y, width: contentWidth, height: 50) (trip.name as NSString).draw(in: titleRect, withAttributes: titleAttributes) y += 55 // Date range let subtitleAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 18), .foregroundColor: UIColor.darkGray ] let dateRect = CGRect(x: margin, y: y, width: contentWidth, height: 30) (trip.formattedDateRange as NSString).draw(in: dateRect, withAttributes: subtitleAttributes) y += 45 // Quick stats row let statsText = "\(trip.stops.count) Cities | \(trip.totalGames) Games | \(trip.formattedTotalDistance)" let statsAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 16, weight: .medium), .foregroundColor: primaryColor ] let statsRect = CGRect(x: margin, y: y, width: contentWidth, height: 25) (statsText as NSString).draw(in: statsRect, withAttributes: statsAttributes) y += 40 // Driving info let drivingText = "\(trip.tripDuration) Days | \(trip.formattedTotalDriving) Total Driving" let drivingAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 14), .foregroundColor: UIColor.gray ] let drivingRect = CGRect(x: margin, y: y, width: contentWidth, height: 22) (drivingText as NSString).draw(in: drivingRect, withAttributes: drivingAttributes) y += 50 // Cities list drawSectionHeader("Route", at: &y) let cityAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 13), .foregroundColor: UIColor.darkGray ] for (index, city) in trip.cities.enumerated() { let cityText = "\(index + 1). \(city)" let cityRect = CGRect(x: margin + 15, y: y, width: contentWidth - 15, height: 20) (cityText as NSString).draw(in: cityRect, withAttributes: cityAttributes) y += 22 } // Footer drawFooter(context: context, pageNumber: 1) } // MARK: - Route Overview Page private func drawRouteOverviewPage( context: UIGraphicsPDFRendererContext, trip: Trip, assets: PDFAssetPrefetcher.PrefetchedAssets? ) { var y: CGFloat = margin // Page header drawPageHeader("Route Overview", at: &y) // Route map (larger on this page) if let routeMap = assets?.routeMap { let mapHeight: CGFloat = 300 let mapRect = CGRect(x: margin, y: y, width: contentWidth, height: mapHeight) let path = UIBezierPath(roundedRect: mapRect, cornerRadius: 8) context.cgContext.saveGState() path.addClip() routeMap.draw(in: mapRect) context.cgContext.restoreGState() UIColor.lightGray.setStroke() path.lineWidth = 1 path.stroke() y += mapHeight + 25 } // Stop table header drawSectionHeader("Stops", at: &y) // Table headers let headerAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 11), .foregroundColor: UIColor.darkGray ] let col1 = margin + 5 let col2 = margin + 35 let col3 = margin + 200 let col4 = margin + 310 let col5 = margin + 410 ("#" as NSString).draw(at: CGPoint(x: col1, y: y), withAttributes: headerAttributes) ("City" as NSString).draw(at: CGPoint(x: col2, y: y), withAttributes: headerAttributes) ("Arrival" as NSString).draw(at: CGPoint(x: col3, y: y), withAttributes: headerAttributes) ("Games" as NSString).draw(at: CGPoint(x: col4, y: y), withAttributes: headerAttributes) ("Distance" as NSString).draw(at: CGPoint(x: col5, y: y), withAttributes: headerAttributes) y += 20 // Separator line drawHorizontalLine(at: y) y += 8 // Table rows let rowAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 11), .foregroundColor: UIColor.black ] let dateFormatter = DateFormatter() dateFormatter.dateFormat = "MMM d" for (index, stop) in trip.stops.enumerated() { ("\(index + 1)" as NSString).draw(at: CGPoint(x: col1, y: y), withAttributes: rowAttributes) ("\(stop.city), \(stop.state)" as NSString).draw(at: CGPoint(x: col2, y: y), withAttributes: rowAttributes) (dateFormatter.string(from: stop.arrivalDate) as NSString).draw(at: CGPoint(x: col3, y: y), withAttributes: rowAttributes) ("\(stop.games.count)" as NSString).draw(at: CGPoint(x: col4, y: y), withAttributes: rowAttributes) // Distance to next stop if index < trip.travelSegments.count { let segment = trip.travelSegments[index] (segment.formattedDistance as NSString).draw(at: CGPoint(x: col5, y: y), withAttributes: rowAttributes) } else { ("--" as NSString).draw(at: CGPoint(x: col5, y: y), withAttributes: rowAttributes) } y += 22 } drawFooter(context: context, pageNumber: 2) } // MARK: - Itinerary Pages private func drawItineraryPages( context: UIGraphicsPDFRendererContext, trip: Trip, games: [String: RichGame], assets: PDFAssetPrefetcher.PrefetchedAssets? ) { var pageNumber = 3 var y: CGFloat = margin var isFirstDayOnPage = true 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 !isFirstDayOnPage { y += 15 // Space between days } y = drawDay( context: context, day: day, games: games, assets: assets, y: y ) isFirstDayOnPage = false } drawFooter(context: context, pageNumber: pageNumber) } private func estimateDayHeight(day: ItineraryDay, games: [String: RichGame]) -> CGFloat { var height: CGFloat = 60 // Day header + city // Games height += CGFloat(day.gameIds.count) * 80 // Each game card // Travel if day.hasTravelSegment { height += 50 } // Rest day if day.isRestDay { height += 30 } return height } private func drawDay( context: UIGraphicsPDFRendererContext, day: ItineraryDay, 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 dayHeader = "Day \(day.dayNumber) - \(day.formattedDate)" (dayHeader as NSString).draw( at: CGPoint(x: margin + 12, y: currentY + 7), withAttributes: dayHeaderAttributes ) currentY += 40 // City if let city = day.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 } // Games for gameId in day.gameIds { if let richGame = games[gameId] { currentY = drawGameCard( context: context, richGame: richGame, assets: assets, y: currentY ) currentY += 10 } } // Travel segments for segment in day.travelSegments { currentY = drawTravelSegment(segment: segment, y: currentY) } // Rest day if day.isRestDay && day.gameIds.isEmpty { 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 } private func drawGameCard( context: UIGraphicsPDFRendererContext, richGame: RichGame, assets: PDFAssetPrefetcher.PrefetchedAssets?, y: CGFloat ) -> CGFloat { let currentY = y // Card background let cardRect = CGRect(x: margin + 10, y: currentY, width: contentWidth - 20, height: 65) let cardPath = UIBezierPath(roundedRect: cardRect, cornerRadius: 8) UIColor.white.setFill() cardPath.fill() // Card border with team color if available let borderColor = teamColor(from: richGame.homeTeam.primaryColor) ?? primaryColor borderColor.withAlphaComponent(0.5).setStroke() cardPath.lineWidth = 1.5 cardPath.stroke() // Sport badge let sportText = richGame.game.sport.rawValue.uppercased() let badgeRect = CGRect(x: margin + 20, y: currentY + 8, width: 35, height: 18) let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 4) borderColor.withAlphaComponent(0.2).setFill() badgePath.fill() let badgeAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 9), .foregroundColor: borderColor ] (sportText as NSString).draw( at: CGPoint(x: badgeRect.midX - 10, y: currentY + 10), withAttributes: badgeAttributes ) // Team logos (if available) let logoSize: CGFloat = 28 let logoY = currentY + 30 // Away team logo if let awayLogo = assets?.teamLogos[richGame.awayTeam.id] { let awayLogoRect = CGRect(x: margin + 20, y: logoY, width: logoSize, height: logoSize) awayLogo.draw(in: awayLogoRect) } else { // Placeholder circle with abbreviation drawTeamPlaceholder( abbreviation: richGame.awayTeam.abbreviation, at: CGPoint(x: margin + 20, y: logoY), size: logoSize, color: teamColor(from: richGame.awayTeam.primaryColor) ) } // "at" text let atAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 11), .foregroundColor: UIColor.gray ] ("@" as NSString).draw(at: CGPoint(x: margin + 55, y: logoY + 8), withAttributes: atAttributes) // Home team logo if let homeLogo = assets?.teamLogos[richGame.homeTeam.id] { let homeLogoRect = CGRect(x: margin + 70, y: logoY, width: logoSize, height: logoSize) homeLogo.draw(in: homeLogoRect) } else { drawTeamPlaceholder( abbreviation: richGame.homeTeam.abbreviation, at: CGPoint(x: margin + 70, y: logoY), size: logoSize, color: teamColor(from: richGame.homeTeam.primaryColor) ) } // Matchup text let matchupAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 13), .foregroundColor: UIColor.black ] let matchupText = "\(richGame.awayTeam.name) at \(richGame.homeTeam.name)" (matchupText as NSString).draw( at: CGPoint(x: margin + 110, y: currentY + 12), withAttributes: matchupAttributes ) // Venue and time let venueAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 11), .foregroundColor: UIColor.darkGray ] let venueText = "\(richGame.stadium.name) | \(richGame.game.gameTime)" (venueText as NSString).draw( at: CGPoint(x: margin + 110, y: currentY + 30), withAttributes: venueAttributes ) // City let cityAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 10), .foregroundColor: UIColor.gray ] let cityText = "\(richGame.stadium.city), \(richGame.stadium.state)" (cityText as NSString).draw( at: CGPoint(x: margin + 110, y: currentY + 46), withAttributes: cityAttributes ) return currentY + 70 } private func drawTravelSegment(segment: TravelSegment, 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 ] let travelText = "Drive to \(segment.toLocation.name) | \(segment.formattedDistance) | \(segment.formattedDuration)" (travelText as NSString).draw( at: CGPoint(x: margin + 40, y: currentY + 10), withAttributes: travelAttributes ) return currentY + 45 } // MARK: - City Spotlight Pages private func drawCitySpotlightPages( context: UIGraphicsPDFRendererContext, trip: Trip, games: [String: RichGame], assets: PDFAssetPrefetcher.PrefetchedAssets? ) { guard let cityPOIs = assets?.cityPOIs, !cityPOIs.isEmpty else { return } var pageNumber = 10 // Estimate, will be overwritten // Draw spotlight for each city with POIs var seenCities: Set = [] for stop in trip.stops { guard !seenCities.contains(stop.city), let pois = cityPOIs[stop.city], !pois.isEmpty else { continue } seenCities.insert(stop.city) context.beginPage() var y: CGFloat = margin drawPageHeader("City Guide: \(stop.city)", at: &y) // City map if let cityMap = assets?.cityMaps[stop.city] { let mapRect = CGRect(x: margin, y: y, width: 250, height: 180) let mapPath = UIBezierPath(roundedRect: mapRect, cornerRadius: 8) context.cgContext.saveGState() mapPath.addClip() cityMap.draw(in: mapRect) context.cgContext.restoreGState() UIColor.lightGray.setStroke() mapPath.lineWidth = 1 mapPath.stroke() // City info next to map let infoX = margin + 270 var infoY = y let cityNameAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 18), .foregroundColor: UIColor.black ] ("\(stop.city), \(stop.state)" as NSString).draw( at: CGPoint(x: infoX, y: infoY), withAttributes: cityNameAttributes ) infoY += 28 // Find stadium for this city let cityGames = stop.games.compactMap { games[$0] } if let stadium = cityGames.first?.stadium { let venueAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 13), .foregroundColor: UIColor.darkGray ] ("Stadium: \(stadium.name)" as NSString).draw( at: CGPoint(x: infoX, y: infoY), withAttributes: venueAttributes ) infoY += 20 let capacityText = "Capacity: \(NumberFormatter.localizedString(from: NSNumber(value: stadium.capacity), number: .decimal))" (capacityText as NSString).draw( at: CGPoint(x: infoX, y: infoY), withAttributes: venueAttributes ) } y += 200 } y += 20 drawSectionHeader("Nearby Attractions", at: &y) // POI list for (index, poi) in pois.enumerated() { y = drawPOIItem(poi: poi, index: index + 1, y: y) } pageNumber += 1 } } private func drawPOIItem(poi: POISearchService.POI, index: Int, y: CGFloat) -> CGFloat { let currentY = y // Number badge let badgeRect = CGRect(x: margin + 10, y: currentY, width: 22, height: 22) let badgePath = UIBezierPath(roundedRect: badgeRect, cornerRadius: 11) accentColor.withAlphaComponent(0.2).setFill() badgePath.fill() let numberAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 11), .foregroundColor: accentColor ] ("\(index)" as NSString).draw( at: CGPoint(x: margin + 17, y: currentY + 4), withAttributes: numberAttributes ) // POI name let nameAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 13), .foregroundColor: UIColor.black ] (poi.name as NSString).draw( at: CGPoint(x: margin + 45, y: currentY + 2), withAttributes: nameAttributes ) // Category and distance let detailAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 11), .foregroundColor: UIColor.gray ] let detailText = "\(poi.category.displayName) | \(poi.formattedDistance) from stadium" (detailText as NSString).draw( at: CGPoint(x: margin + 45, y: currentY + 20), withAttributes: detailAttributes ) // Address if available if let address = poi.address { let addressAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 10), .foregroundColor: UIColor.lightGray ] (address as NSString).draw( at: CGPoint(x: margin + 45, y: currentY + 36), withAttributes: addressAttributes ) return currentY + 55 } return currentY + 45 } // MARK: - Summary Page private func drawSummaryPage( context: UIGraphicsPDFRendererContext, trip: Trip, games: [String: RichGame] ) { var y: CGFloat = margin drawPageHeader("Trip Summary", at: &y) // Stats grid let statsBoxWidth: CGFloat = 150 let statsBoxHeight: CGFloat = 80 let spacing: CGFloat = 20 let stats: [(String, String)] = [ ("\(trip.tripDuration)", "Days"), ("\(trip.stops.count)", "Cities"), ("\(trip.totalGames)", "Games"), (String(format: "%.0f", trip.totalDistanceMiles), "Miles") ] var col: CGFloat = 0 for (index, stat) in stats.enumerated() { let x = margin + (statsBoxWidth + spacing) * CGFloat(index % 3) let boxY = y + (index >= 3 ? statsBoxHeight + 15 : 0) if index == 3 && col < 3 { col = 0 // New row } let boxRect = CGRect(x: x, y: boxY, width: statsBoxWidth, height: statsBoxHeight) let boxPath = UIBezierPath(roundedRect: boxRect, cornerRadius: 10) primaryColor.withAlphaComponent(0.08).setFill() boxPath.fill() // Big number let numberAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 28), .foregroundColor: primaryColor ] let numberSize = (stat.0 as NSString).size(withAttributes: numberAttributes) (stat.0 as NSString).draw( at: CGPoint(x: x + (statsBoxWidth - numberSize.width) / 2, y: boxY + 15), withAttributes: numberAttributes ) // Label let labelAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 13), .foregroundColor: UIColor.darkGray ] let labelSize = (stat.1 as NSString).size(withAttributes: labelAttributes) (stat.1 as NSString).draw( at: CGPoint(x: x + (statsBoxWidth - labelSize.width) / 2, y: boxY + 50), withAttributes: labelAttributes ) col += 1 } y += statsBoxHeight * 2 + 50 // Driving stats drawSectionHeader("Driving", at: &y) let drivingStats = [ ("Total Driving Time", trip.formattedTotalDriving), ("Average Per Day", String(format: "%.1f hours", trip.averageDrivingHoursPerDay)), ("Travel Segments", "\(trip.travelSegments.count)") ] for stat in drivingStats { drawStatRow(label: stat.0, value: stat.1, y: &y) } y += 20 // Sports breakdown drawSectionHeader("Sports Breakdown", at: &y) var sportCounts: [Sport: Int] = [:] for gameId in trip.stops.flatMap({ $0.games }) { if let game = games[gameId] { sportCounts[game.game.sport, default: 0] += 1 } } for (sport, count) in sportCounts.sorted(by: { $0.value > $1.value }) { drawStatRow(label: sport.rawValue, value: "\(count) games", y: &y) } // Trip score if available if let score = trip.score { y += 20 drawSectionHeader("Trip Score", at: &y) let gradeAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 36), .foregroundColor: UIColor.systemGreen ] let gradeText = score.scoreGrade (gradeText as NSString).draw(at: CGPoint(x: margin + 20, y: y), withAttributes: gradeAttributes) let scoreAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 16), .foregroundColor: UIColor.darkGray ] let scoreText = "\(score.formattedOverallScore)/100" (scoreText as NSString).draw(at: CGPoint(x: margin + 80, y: y + 10), withAttributes: scoreAttributes) } // Footer let footerY = pageHeight - 50 let footerAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.italicSystemFont(ofSize: 10), .foregroundColor: UIColor.lightGray ] ("Generated by SportsTime" as NSString).draw( at: CGPoint(x: margin, y: footerY), withAttributes: footerAttributes ) } // MARK: - Drawing Helpers private func drawPageHeader(_ title: String, at y: inout CGFloat) { let headerAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 22), .foregroundColor: UIColor.black ] (title as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: headerAttributes) y += 35 } private func drawSectionHeader(_ title: String, at y: inout CGFloat) { let headerAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 14), .foregroundColor: primaryColor ] (title as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: headerAttributes) y += 22 } private func drawStatRow(label: String, value: String, y: inout CGFloat) { let labelAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 12), .foregroundColor: UIColor.darkGray ] let valueAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 12), .foregroundColor: UIColor.black ] (label as NSString).draw(at: CGPoint(x: margin + 15, y: y), withAttributes: labelAttributes) (value as NSString).draw(at: CGPoint(x: margin + 200, y: y), withAttributes: valueAttributes) y += 20 } private func drawHorizontalLine(at y: CGFloat, alpha: CGFloat = 0.5) { let path = UIBezierPath() path.move(to: CGPoint(x: margin, y: y)) path.addLine(to: CGPoint(x: pageWidth - margin, y: y)) UIColor.lightGray.withAlphaComponent(alpha).setStroke() path.lineWidth = 0.5 path.stroke() } private func drawFooter(context: UIGraphicsPDFRendererContext, pageNumber: Int) { let footerY = pageHeight - 35 let footerAttributes: [NSAttributedString.Key: Any] = [ .font: UIFont.systemFont(ofSize: 9), .foregroundColor: UIColor.lightGray ] let footerText = "Page \(pageNumber)" let footerSize = (footerText as NSString).size(withAttributes: footerAttributes) (footerText as NSString).draw( at: CGPoint(x: (pageWidth - footerSize.width) / 2, y: footerY), withAttributes: footerAttributes ) } private func drawTeamPlaceholder(abbreviation: String, at point: CGPoint, size: CGFloat, color: UIColor?) { let rect = CGRect(origin: point, size: CGSize(width: size, height: size)) let path = UIBezierPath(ovalIn: rect) (color ?? primaryColor).withAlphaComponent(0.2).setFill() path.fill() let attributes: [NSAttributedString.Key: Any] = [ .font: UIFont.boldSystemFont(ofSize: 10), .foregroundColor: color ?? primaryColor ] let textSize = (abbreviation as NSString).size(withAttributes: attributes) (abbreviation as NSString).draw( at: CGPoint( x: point.x + (size - textSize.width) / 2, y: point.y + (size - textSize.height) / 2 ), withAttributes: attributes ) } private func teamColor(from hexString: String?) -> UIColor? { guard let hex = hexString else { return nil } return UIColor(hex: hex) } } // 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 func exportToPDF( trip: Trip, games: [String: RichGame], progressCallback: ((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 let data = try await pdfGenerator.generatePDF(for: trip, games: games, assets: assets) // 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]) async throws -> URL { let data = try await pdfGenerator.generatePDF(for: trip, games: games, assets: nil) 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) } }