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>
This commit is contained in:
344
SportsTime/Export/PDFGenerator.swift
Normal file
344
SportsTime/Export/PDFGenerator.swift
Normal file
@@ -0,0 +1,344 @@
|
||||
//
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user