- 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>
936 lines
33 KiB
Swift
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)
|
|
}
|
|
}
|