Files
Sportstime/SportsTime/Export/PDFGenerator.swift
Trey t 1e26cfebc8 fix: standardize trip name display with arrow separators app-wide
- Add `displayName` computed property to Trip model that always
  generates city list with " → " separator for consistent display
- Replace all `trip.name` usages with `trip.displayName` in UI files
- Update SuggestedTripsGenerator to use " → " separator
- Update PDFGenerator to use displayName for PDF titles

This ensures all trip names display consistently regardless of when
the trip was created or how the name was originally stored.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 11:36:13 -06:00

936 lines
33 KiB
Swift

//
// 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.displayName 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.localGameTimeShort)"
(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<String> = []
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)
}
}