Files
Sportstime/SportsTime/Export/PDFGenerator.swift
Trey t 9088b46563 Initial commit: SportsTime trip planning app
- Three-scenario planning engine (A: date range, B: selected games, C: directional routes)
- GeographicRouteExplorer with anchor game support for route exploration
- Shared ItineraryBuilder for travel segment calculation
- TravelEstimator for driving time/distance estimation
- SwiftUI views for trip creation and detail display
- CloudKit integration for schedule data
- Python scraping scripts for sports schedules

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 00:46:40 -06:00

345 lines
12 KiB
Swift

//
// PDFGenerator.swift
// SportsTime
//
import Foundation
import PDFKit
import UIKit
actor PDFGenerator {
// MARK: - Generate PDF
func generatePDF(for trip: Trip, games: [UUID: RichGame]) async throws -> Data {
let pageWidth: CGFloat = 612 // Letter size
let pageHeight: CGFloat = 792
let margin: CGFloat = 50
let pdfRenderer = UIGraphicsPDFRenderer(bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight))
let data = pdfRenderer.pdfData { context in
var currentY: CGFloat = margin
// Page 1: Cover
context.beginPage()
currentY = drawCoverPage(
context: context,
trip: trip,
pageWidth: pageWidth,
margin: margin
)
// Page 2+: Itinerary
context.beginPage()
currentY = margin
currentY = drawItineraryHeader(
context: context,
y: currentY,
pageWidth: pageWidth,
margin: margin
)
for day in trip.itineraryDays() {
// Check if we need a new page
if currentY > pageHeight - 200 {
context.beginPage()
currentY = margin
}
currentY = drawDay(
context: context,
day: day,
games: games,
y: currentY,
pageWidth: pageWidth,
margin: margin
)
currentY += 20 // Space between days
}
// Summary page
context.beginPage()
drawSummaryPage(
context: context,
trip: trip,
pageWidth: pageWidth,
margin: margin
)
}
return data
}
// MARK: - Cover Page
private func drawCoverPage(
context: UIGraphicsPDFRendererContext,
trip: Trip,
pageWidth: CGFloat,
margin: CGFloat
) -> CGFloat {
var y: CGFloat = 150
// Title
let titleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 32),
.foregroundColor: UIColor.black
]
let title = trip.name
let titleRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 50)
(title as NSString).draw(in: titleRect, withAttributes: titleAttributes)
y += 60
// Date range
let subtitleAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 18),
.foregroundColor: UIColor.darkGray
]
let dateRange = trip.formattedDateRange
let dateRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 30)
(dateRange as NSString).draw(in: dateRect, withAttributes: subtitleAttributes)
y += 50
// Quick stats
let statsAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor.gray
]
let stats = """
\(trip.stops.count) Cities • \(trip.totalGames) Games • \(trip.formattedTotalDistance)
\(trip.tripDuration) Days • \(trip.formattedTotalDriving) Driving
"""
let statsRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 50)
(stats as NSString).draw(in: statsRect, withAttributes: statsAttributes)
y += 80
// Cities list
let citiesTitle = "Cities Visited"
let citiesTitleRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 25)
(citiesTitle as NSString).draw(in: citiesTitleRect, withAttributes: [
.font: UIFont.boldSystemFont(ofSize: 16),
.foregroundColor: UIColor.black
])
y += 30
for city in trip.cities {
let cityRect = CGRect(x: margin + 20, y: y, width: pageWidth - margin * 2 - 20, height: 20)
("\(city)" as NSString).draw(in: cityRect, withAttributes: statsAttributes)
y += 22
}
return y
}
// MARK: - Itinerary Header
private func drawItineraryHeader(
context: UIGraphicsPDFRendererContext,
y: CGFloat,
pageWidth: CGFloat,
margin: CGFloat
) -> CGFloat {
let headerAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 24),
.foregroundColor: UIColor.black
]
let header = "Day-by-Day Itinerary"
let headerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 35)
(header as NSString).draw(in: headerRect, withAttributes: headerAttributes)
return y + 50
}
// MARK: - Day Section
private func drawDay(
context: UIGraphicsPDFRendererContext,
day: ItineraryDay,
games: [UUID: RichGame],
y: CGFloat,
pageWidth: CGFloat,
margin: CGFloat
) -> CGFloat {
var currentY = y
// Day header
let dayHeaderAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 16),
.foregroundColor: UIColor.systemBlue
]
let dayHeader = "Day \(day.dayNumber): \(day.formattedDate)"
let dayHeaderRect = CGRect(x: margin, y: currentY, width: pageWidth - margin * 2, height: 25)
(dayHeader as NSString).draw(in: dayHeaderRect, withAttributes: dayHeaderAttributes)
currentY += 28
// City
if let city = day.primaryCity {
let cityAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor.darkGray
]
let cityRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
("📍 \(city)" as NSString).draw(in: cityRect, withAttributes: cityAttributes)
currentY += 24
}
// Travel segment
if day.hasTravelSegment {
for segment in day.travelSegments {
let travelText = "🚗 \(segment.fromLocation.name)\(segment.toLocation.name) (\(segment.formattedDistance), \(segment.formattedDuration))"
let travelRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
(travelText as NSString).draw(in: travelRect, withAttributes: [
.font: UIFont.systemFont(ofSize: 12),
.foregroundColor: UIColor.gray
])
currentY += 22
}
}
// Games
for gameId in day.gameIds {
if let richGame = games[gameId] {
let gameText = "\(richGame.fullMatchupDescription)"
let gameRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
(gameText as NSString).draw(in: gameRect, withAttributes: [
.font: UIFont.systemFont(ofSize: 13),
.foregroundColor: UIColor.black
])
currentY += 20
let venueText = " \(richGame.venueDescription)\(richGame.game.gameTime)"
let venueRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 18)
(venueText as NSString).draw(in: venueRect, withAttributes: [
.font: UIFont.systemFont(ofSize: 11),
.foregroundColor: UIColor.gray
])
currentY += 22
}
}
// Rest day indicator
if day.isRestDay {
let restText = "😴 Rest Day"
let restRect = CGRect(x: margin + 10, y: currentY, width: pageWidth - margin * 2 - 10, height: 20)
(restText as NSString).draw(in: restRect, withAttributes: [
.font: UIFont.italicSystemFont(ofSize: 12),
.foregroundColor: UIColor.gray
])
currentY += 22
}
// Separator line
currentY += 5
let path = UIBezierPath()
path.move(to: CGPoint(x: margin, y: currentY))
path.addLine(to: CGPoint(x: pageWidth - margin, y: currentY))
UIColor.lightGray.setStroke()
path.lineWidth = 0.5
path.stroke()
currentY += 10
return currentY
}
// MARK: - Summary Page
private func drawSummaryPage(
context: UIGraphicsPDFRendererContext,
trip: Trip,
pageWidth: CGFloat,
margin: CGFloat
) {
var y: CGFloat = margin
// Header
let headerAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.boldSystemFont(ofSize: 24),
.foregroundColor: UIColor.black
]
let header = "Trip Summary"
let headerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 35)
(header as NSString).draw(in: headerRect, withAttributes: headerAttributes)
y += 50
// Stats
let statAttributes: [NSAttributedString.Key: Any] = [
.font: UIFont.systemFont(ofSize: 14),
.foregroundColor: UIColor.black
]
let stats = [
("Total Duration", "\(trip.tripDuration) days"),
("Total Distance", trip.formattedTotalDistance),
("Total Driving Time", trip.formattedTotalDriving),
("Average Daily Driving", String(format: "%.1f hours", trip.averageDrivingHoursPerDay)),
("Cities Visited", "\(trip.stops.count)"),
("Games Attended", "\(trip.totalGames)"),
("Sports", trip.uniqueSports.map { $0.rawValue }.joined(separator: ", "))
]
for (label, value) in stats {
let labelRect = CGRect(x: margin, y: y, width: 200, height: 22)
("\(label):" as NSString).draw(in: labelRect, withAttributes: [
.font: UIFont.boldSystemFont(ofSize: 13),
.foregroundColor: UIColor.darkGray
])
let valueRect = CGRect(x: margin + 200, y: y, width: pageWidth - margin * 2 - 200, height: 22)
(value as NSString).draw(in: valueRect, withAttributes: statAttributes)
y += 26
}
// Score (if available)
if let score = trip.score {
y += 20
let scoreHeader = "Trip Score: \(score.scoreGrade) (\(score.formattedOverallScore)/100)"
let scoreRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 30)
(scoreHeader as NSString).draw(in: scoreRect, withAttributes: [
.font: UIFont.boldSystemFont(ofSize: 18),
.foregroundColor: UIColor.systemGreen
])
}
// Footer
y = 720
let footerText = "Generated by Sport Travel Planner"
let footerRect = CGRect(x: margin, y: y, width: pageWidth - margin * 2, height: 20)
(footerText as NSString).draw(in: footerRect, withAttributes: [
.font: UIFont.italicSystemFont(ofSize: 10),
.foregroundColor: UIColor.lightGray
])
}
}
// MARK: - Export Service
actor ExportService {
private let pdfGenerator = PDFGenerator()
func exportToPDF(trip: Trip, games: [UUID: RichGame]) async throws -> URL {
let data = try await pdfGenerator.generatePDF(for: trip, games: games)
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? {
// Generate a shareable deep link
// In production, this would create a proper share URL
let baseURL = "sportstime://trip/"
return URL(string: baseURL + trip.id.uuidString)
}
}