Files
Sportstime/SportsTime/Export/PDFGenerator.swift
2026-02-18 13:00:15 -06:00

874 lines
32 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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)
}
}