- 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>
345 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|