Fixes ~95 issues from deep audit across 12 categories in 82 files: - Crash prevention: double-resume in PhotoMetadataExtractor, force unwraps in DateRangePicker, array bounds checks in polls/achievements, ProGate hit-test bypass, Dictionary(uniqueKeysWithValues:) → uniquingKeysWith in 4 files - Silent failure elimination: all 34 try? sites replaced with do/try/catch + logging (SavedTrip, TripDetailView, CanonicalSyncService, BootstrapService, CanonicalModels, CKModels, SportsTimeApp, and more) - Performance: cached DateFormatters (7 files), O(1) team lookups via AppDataProvider, achievement definition dictionary, AnimatedBackground consolidated from 19 Tasks to 1, task cancellation in SharePreviewView - Concurrency: UIKit drawing → MainActor.run, background fetch timeout guard, @MainActor on ThemeManager/AppearanceManager, SyncLogger read/write race fix - Planning engine: game end time in travel feasibility, state-aware city normalization, exact city matching, DrivingConstraints parameter propagation - IAP: unknown subscription states → expired, unverified transaction logging, entitlements updated before paywall dismiss, restore visible to all users - Security: API key to Info.plist lookup, filename sanitization in PDF export, honest User-Agent, removed stale "Feels" analytics super properties - Navigation: consolidated competing navigationDestination, boolean → value-based - Testing: 8 sleep() → waitForExistence, duplicates extracted, Swift 6 compat - Service bugs: infinite retry cap, duplicate achievement prevention, TOCTOU vote fix, PollVote.odg → voterId rename, deterministic placeholder IDs, parallel MKDirections, Sendable-safe POI struct Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
885 lines
32 KiB
Swift
885 lines
32 KiB
Swift
//
|
||
// PDFGenerator.swift
|
||
// SportsTime
|
||
//
|
||
// Generates magazine-style PDF trip itineraries with clean typography and generous whitespace.
|
||
//
|
||
|
||
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 = 54 // Print-safe margins
|
||
private let contentWidth: CGFloat = 504 // 612 - 54*2
|
||
|
||
// Header/footer heights
|
||
private let headerHeight: CGFloat = 0 // No top header (title in cover only)
|
||
private let footerHeight: CGFloat = 40 // Room for trip info + page number
|
||
|
||
// Magazine-style spacing
|
||
private let sectionSpacing: CGFloat = 28
|
||
private let itemSpacing: CGFloat = 16
|
||
private let daySpacing: CGFloat = 36
|
||
|
||
// 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) // SportsTime orange
|
||
private let textPrimary = UIColor.black
|
||
private let textSecondary = UIColor.darkGray
|
||
private let textMuted = UIColor.gray
|
||
|
||
// Sport-specific accent colors
|
||
private func sportAccentColor(for sport: Sport) -> UIColor {
|
||
switch sport {
|
||
case .mlb: return UIColor(red: 0.76, green: 0.09, blue: 0.16, alpha: 1.0) // Baseball red
|
||
case .nba: return UIColor(red: 0.91, green: 0.33, blue: 0.11, alpha: 1.0) // Basketball orange
|
||
case .nhl: return UIColor(red: 0.0, green: 0.32, blue: 0.58, alpha: 1.0) // Hockey blue
|
||
case .nfl: return UIColor(red: 0.0, green: 0.21, blue: 0.38, alpha: 1.0) // Football navy
|
||
case .mls: return UIColor(red: 0.0, green: 0.5, blue: 0.35, alpha: 1.0) // Soccer green
|
||
case .wnba: return UIColor(red: 0.98, green: 0.33, blue: 0.14, alpha: 1.0) // WNBA orange
|
||
case .nwsl: return UIColor(red: 0.0, green: 0.45, blue: 0.65, alpha: 1.0) // NWSL blue
|
||
}
|
||
}
|
||
|
||
// MARK: - Trip Info (for footer)
|
||
|
||
private var currentTrip: Trip?
|
||
private var totalPages: Int = 1
|
||
|
||
// MARK: - Generate PDF
|
||
|
||
func generatePDF(
|
||
for trip: Trip,
|
||
games: [String: RichGame],
|
||
assets: PDFAssetPrefetcher.PrefetchedAssets? = nil,
|
||
itineraryItems: [ItineraryItem]? = nil
|
||
) async throws -> Data {
|
||
// Store trip for footer access
|
||
currentTrip = trip
|
||
|
||
// Calculate total pages for footer
|
||
totalPages = calculateTotalPages(trip: trip, games: games, itineraryItems: itineraryItems)
|
||
|
||
let pdfRenderer = UIGraphicsPDFRenderer(
|
||
bounds: CGRect(x: 0, y: 0, width: pageWidth, height: pageHeight)
|
||
)
|
||
|
||
let data = pdfRenderer.pdfData { context in
|
||
// Page 1: Cover with hero map
|
||
context.beginPage()
|
||
drawCoverPage(context: context, trip: trip, assets: assets)
|
||
|
||
// Pages 2+: Day-by-Day Itinerary (magazine-style continuous flow)
|
||
drawItineraryPages(context: context, trip: trip, games: games, assets: assets, itineraryItems: itineraryItems)
|
||
}
|
||
|
||
currentTrip = nil
|
||
return data
|
||
}
|
||
|
||
/// Estimate total pages for footer display
|
||
private func calculateTotalPages(trip: Trip, games: [String: RichGame], itineraryItems: [ItineraryItem]?) -> Int {
|
||
var pages = 1 // Cover page
|
||
|
||
let usableHeight = pageHeight - margin - footerHeight - 50 // Content area per page
|
||
var currentPageUsed: CGFloat = 0
|
||
let allTripDays = trip.itineraryDays()
|
||
|
||
if let items = itineraryItems, !items.isEmpty {
|
||
let groupedItems = Dictionary(grouping: items) { $0.day }
|
||
// Iterate over ALL trip days, not just days with items
|
||
for day in allTripDays {
|
||
let dayItems = groupedItems[day.dayNumber] ?? []
|
||
let dayHeight = estimateItemsDayHeight(items: dayItems, games: games)
|
||
|
||
if currentPageUsed + dayHeight > usableHeight {
|
||
pages += 1
|
||
currentPageUsed = dayHeight
|
||
} else {
|
||
currentPageUsed += dayHeight + daySpacing
|
||
}
|
||
}
|
||
} else {
|
||
for day in trip.itineraryDays() {
|
||
let dayHeight = estimateDayHeight(day: day, games: games)
|
||
|
||
if currentPageUsed + dayHeight > usableHeight {
|
||
pages += 1
|
||
currentPageUsed = dayHeight
|
||
} else {
|
||
currentPageUsed += dayHeight + daySpacing
|
||
}
|
||
}
|
||
}
|
||
|
||
// Add one more page for any remaining content
|
||
if currentPageUsed > 0 {
|
||
pages += 1
|
||
}
|
||
|
||
return max(2, pages) // At least cover + 1 itinerary page
|
||
}
|
||
|
||
// MARK: - Cover Page
|
||
|
||
private func drawCoverPage(
|
||
context: UIGraphicsPDFRendererContext,
|
||
trip: Trip,
|
||
assets: PDFAssetPrefetcher.PrefetchedAssets?
|
||
) {
|
||
var y: CGFloat = margin
|
||
|
||
// Hero route map (full width, large)
|
||
if let routeMap = assets?.routeMap {
|
||
let mapHeight: CGFloat = 320
|
||
let mapRect = CGRect(x: margin, y: y, width: contentWidth, height: mapHeight)
|
||
|
||
// Draw map with rounded corners
|
||
let path = UIBezierPath(roundedRect: mapRect, cornerRadius: 16)
|
||
context.cgContext.saveGState()
|
||
path.addClip()
|
||
routeMap.draw(in: mapRect)
|
||
context.cgContext.restoreGState()
|
||
|
||
// Subtle border
|
||
UIColor.lightGray.withAlphaComponent(0.5).setStroke()
|
||
path.lineWidth = 1
|
||
path.stroke()
|
||
|
||
y += mapHeight + sectionSpacing
|
||
} else {
|
||
y = 140
|
||
}
|
||
|
||
// Accent bar
|
||
let barRect = CGRect(x: margin, y: y, width: 80, height: 4)
|
||
let barPath = UIBezierPath(roundedRect: barRect, cornerRadius: 2)
|
||
accentColor.setFill()
|
||
barPath.fill()
|
||
y += 20
|
||
|
||
// Trip name (large, bold)
|
||
let titleFont = UIFont.systemFont(ofSize: 34, weight: .bold)
|
||
let titleAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: titleFont,
|
||
.foregroundColor: textPrimary
|
||
]
|
||
let titleRect = CGRect(x: margin, y: y, width: contentWidth, height: 45)
|
||
(trip.displayName as NSString).draw(in: titleRect, withAttributes: titleAttributes)
|
||
y += 50
|
||
|
||
// Date range (formatted nicely)
|
||
let dateRangeText = formatCoverDateRange(trip: trip)
|
||
let dateAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 18, weight: .regular),
|
||
.foregroundColor: textSecondary
|
||
]
|
||
(dateRangeText as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: dateAttributes)
|
||
y += 36
|
||
|
||
// Quick stats with bullet separators
|
||
let statsText = "\(trip.stops.count) Cities • \(trip.totalGames) Games • \(formatMiles(trip.totalDistanceMiles))"
|
||
let statsAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 15, weight: .medium),
|
||
.foregroundColor: primaryColor
|
||
]
|
||
(statsText as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: statsAttributes)
|
||
y += 28
|
||
|
||
// Driving summary
|
||
let drivingText = "\(trip.tripDuration) Days • \(trip.formattedTotalDriving) driving"
|
||
let drivingAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 14),
|
||
.foregroundColor: textMuted
|
||
]
|
||
(drivingText as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: drivingAttributes)
|
||
y += sectionSpacing + 12
|
||
|
||
// Route overview (compact city list)
|
||
drawCoverSectionHeader("Route", at: &y)
|
||
|
||
let cityFont = UIFont.systemFont(ofSize: 13)
|
||
let cityAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: cityFont,
|
||
.foregroundColor: textSecondary
|
||
]
|
||
|
||
// Draw cities in a flowing layout
|
||
var cityLine = ""
|
||
for (index, city) in trip.cities.enumerated() {
|
||
let separator = index == 0 ? "" : " → "
|
||
let testLine = cityLine + separator + city
|
||
|
||
// Check if we need to wrap
|
||
let testSize = (testLine as NSString).size(withAttributes: cityAttributes)
|
||
if testSize.width > contentWidth - 20 && !cityLine.isEmpty {
|
||
(cityLine as NSString).draw(at: CGPoint(x: margin + 12, y: y), withAttributes: cityAttributes)
|
||
y += 22
|
||
cityLine = city
|
||
} else {
|
||
cityLine = testLine
|
||
}
|
||
}
|
||
// Draw remaining
|
||
if !cityLine.isEmpty {
|
||
(cityLine as NSString).draw(at: CGPoint(x: margin + 12, y: y), withAttributes: cityAttributes)
|
||
y += 22
|
||
}
|
||
|
||
// Footer
|
||
drawFooter(context: context, pageNumber: 1)
|
||
}
|
||
|
||
/// Format date range for cover: "Jan 18 – Jan 25, 2026"
|
||
private func formatCoverDateRange(trip: Trip) -> String {
|
||
let formatter = DateFormatter()
|
||
|
||
// Get first and last dates
|
||
guard let firstStop = trip.stops.first,
|
||
let lastStop = trip.stops.last else {
|
||
return trip.formattedDateRange
|
||
}
|
||
|
||
let startDate = firstStop.arrivalDate
|
||
let endDate = lastStop.departureDate
|
||
|
||
// Same year?
|
||
let startYear = Calendar.current.component(.year, from: startDate)
|
||
let endYear = Calendar.current.component(.year, from: endDate)
|
||
|
||
if startYear == endYear {
|
||
formatter.dateFormat = "MMM d"
|
||
let startStr = formatter.string(from: startDate)
|
||
formatter.dateFormat = "MMM d, yyyy"
|
||
let endStr = formatter.string(from: endDate)
|
||
return "\(startStr) – \(endStr)"
|
||
} else {
|
||
formatter.dateFormat = "MMM d, yyyy"
|
||
return "\(formatter.string(from: startDate)) – \(formatter.string(from: endDate))"
|
||
}
|
||
}
|
||
|
||
/// Format miles: "1,234 mi"
|
||
private func formatMiles(_ miles: Double) -> String {
|
||
let formatter = NumberFormatter()
|
||
formatter.numberStyle = .decimal
|
||
formatter.maximumFractionDigits = 0
|
||
let formatted = formatter.string(from: NSNumber(value: miles)) ?? "\(Int(miles))"
|
||
return "\(formatted) mi"
|
||
}
|
||
|
||
private func drawCoverSectionHeader(_ title: String, at y: inout CGFloat) {
|
||
let attributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 12, weight: .semibold),
|
||
.foregroundColor: textMuted
|
||
]
|
||
(title.uppercased() as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: attributes)
|
||
y += 20
|
||
}
|
||
|
||
// MARK: - Enhanced Footer
|
||
|
||
private func drawFooter(context: UIGraphicsPDFRendererContext, pageNumber: Int) {
|
||
let footerY = pageHeight - footerHeight
|
||
|
||
// Separator line
|
||
let linePath = UIBezierPath()
|
||
linePath.move(to: CGPoint(x: margin, y: footerY))
|
||
linePath.addLine(to: CGPoint(x: pageWidth - margin, y: footerY))
|
||
UIColor.lightGray.withAlphaComponent(0.3).setStroke()
|
||
linePath.lineWidth = 0.5
|
||
linePath.stroke()
|
||
|
||
let textY = footerY + 12
|
||
|
||
// Left: SportsTime branding
|
||
let brandAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 9, weight: .semibold),
|
||
.foregroundColor: accentColor
|
||
]
|
||
("SportsTime" as NSString).draw(at: CGPoint(x: margin, y: textY), withAttributes: brandAttributes)
|
||
|
||
// Center: Trip name + dates
|
||
if let trip = currentTrip {
|
||
let centerText = "\(trip.displayName) • \(formatCoverDateRange(trip: trip))"
|
||
let centerAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 9),
|
||
.foregroundColor: textMuted
|
||
]
|
||
let centerSize = (centerText as NSString).size(withAttributes: centerAttributes)
|
||
let centerX = (pageWidth - centerSize.width) / 2
|
||
(centerText as NSString).draw(at: CGPoint(x: centerX, y: textY), withAttributes: centerAttributes)
|
||
}
|
||
|
||
// Right: Page number
|
||
let pageText = "Page \(pageNumber)"
|
||
let pageAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 9),
|
||
.foregroundColor: textMuted
|
||
]
|
||
let pageSize = (pageText as NSString).size(withAttributes: pageAttributes)
|
||
(pageText as NSString).draw(
|
||
at: CGPoint(x: pageWidth - margin - pageSize.width, y: textY),
|
||
withAttributes: pageAttributes
|
||
)
|
||
}
|
||
|
||
// MARK: - Itinerary Pages
|
||
|
||
private func drawItineraryPages(
|
||
context: UIGraphicsPDFRendererContext,
|
||
trip: Trip,
|
||
games: [String: RichGame],
|
||
assets: PDFAssetPrefetcher.PrefetchedAssets?,
|
||
itineraryItems: [ItineraryItem]?
|
||
) {
|
||
var pageNumber = 2
|
||
var y: CGFloat = margin
|
||
var isFirstDayOnPage = true
|
||
|
||
context.beginPage()
|
||
drawItineraryPageHeader(at: &y)
|
||
|
||
// Usable content height (accounting for footer)
|
||
let usableHeight = pageHeight - footerHeight - 20
|
||
|
||
// Get all trip days to ensure we render every day
|
||
let allTripDays = trip.itineraryDays()
|
||
|
||
// If we have custom itinerary items, use their order; otherwise fall back to derived order
|
||
if let items = itineraryItems, !items.isEmpty {
|
||
let groupedItems = Dictionary(grouping: items) { $0.day }
|
||
|
||
// Iterate over ALL trip days, not just days with items
|
||
for day in allTripDays {
|
||
let dayNumber = day.dayNumber
|
||
let dayItems = groupedItems[dayNumber]?.sorted(by: { $0.sortOrder < $1.sortOrder }) ?? []
|
||
|
||
let estimatedHeight = estimateItemsDayHeight(items: dayItems, games: games)
|
||
if y + estimatedHeight > usableHeight {
|
||
drawFooter(context: context, pageNumber: pageNumber)
|
||
pageNumber += 1
|
||
context.beginPage()
|
||
y = margin
|
||
isFirstDayOnPage = true
|
||
}
|
||
|
||
if !isFirstDayOnPage {
|
||
y += daySpacing
|
||
}
|
||
|
||
y = drawDayWithItems(
|
||
context: context,
|
||
dayNumber: dayNumber,
|
||
date: day.date,
|
||
items: dayItems,
|
||
games: games,
|
||
assets: assets,
|
||
y: y
|
||
)
|
||
|
||
isFirstDayOnPage = false
|
||
}
|
||
} else {
|
||
for day in trip.itineraryDays() {
|
||
let estimatedHeight = estimateDayHeight(day: day, games: games)
|
||
if y + estimatedHeight > usableHeight {
|
||
drawFooter(context: context, pageNumber: pageNumber)
|
||
pageNumber += 1
|
||
context.beginPage()
|
||
y = margin
|
||
isFirstDayOnPage = true
|
||
}
|
||
|
||
if !isFirstDayOnPage {
|
||
y += daySpacing
|
||
}
|
||
|
||
y = drawDay(
|
||
context: context,
|
||
day: day,
|
||
games: games,
|
||
assets: assets,
|
||
y: y
|
||
)
|
||
|
||
isFirstDayOnPage = false
|
||
}
|
||
}
|
||
|
||
drawFooter(context: context, pageNumber: pageNumber)
|
||
}
|
||
|
||
private func drawItineraryPageHeader(at y: inout CGFloat) {
|
||
let attributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 11, weight: .semibold),
|
||
.foregroundColor: textMuted
|
||
]
|
||
("ITINERARY" as NSString).draw(at: CGPoint(x: margin, y: y), withAttributes: attributes)
|
||
y += sectionSpacing
|
||
}
|
||
|
||
/// Estimate height for a day rendered from ItineraryItems
|
||
private func estimateItemsDayHeight(items: [ItineraryItem], games: [String: RichGame]) -> CGFloat {
|
||
var height: CGFloat = 50 // Day header
|
||
|
||
for item in items {
|
||
switch item.kind {
|
||
case .game:
|
||
height += 44 + itemSpacing // Simplified game entry
|
||
case .travel:
|
||
height += 36 + itemSpacing // Travel segment
|
||
case .custom:
|
||
height += 40 + itemSpacing // Custom item
|
||
}
|
||
}
|
||
|
||
return height
|
||
}
|
||
|
||
/// Draw a day using ItineraryItems for order (magazine-style)
|
||
private func drawDayWithItems(
|
||
context: UIGraphicsPDFRendererContext,
|
||
dayNumber: Int,
|
||
date: Date?,
|
||
items: [ItineraryItem],
|
||
games: [String: RichGame],
|
||
assets: PDFAssetPrefetcher.PrefetchedAssets?,
|
||
y: CGFloat
|
||
) -> CGFloat {
|
||
var currentY = y
|
||
|
||
// Day header: "Day 1 • Sat, Jan 18"
|
||
let dayHeaderAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 16, weight: .bold),
|
||
.foregroundColor: textPrimary
|
||
]
|
||
|
||
var dayHeaderText = "Day \(dayNumber)"
|
||
if let date = date {
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "EEE, MMM d" // "Sat, Jan 18"
|
||
dayHeaderText += " • \(formatter.string(from: date))"
|
||
}
|
||
|
||
(dayHeaderText as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: dayHeaderAttributes)
|
||
currentY += 28
|
||
|
||
// Location subtitle (if we can determine it)
|
||
let primaryCity = determinePrimaryCity(from: items, games: games)
|
||
if let city = primaryCity {
|
||
let locationAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 12),
|
||
.foregroundColor: textMuted
|
||
]
|
||
(city as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: locationAttributes)
|
||
currentY += 22
|
||
}
|
||
|
||
currentY += 8 // Space before items
|
||
|
||
// Draw items in sortOrder
|
||
var hasContent = false
|
||
for item in items {
|
||
switch item.kind {
|
||
case .game(let gameId, _):
|
||
if let richGame = games[gameId] {
|
||
currentY = drawGameEntry(richGame: richGame, y: currentY)
|
||
currentY += itemSpacing
|
||
hasContent = true
|
||
}
|
||
case .travel(let travelInfo):
|
||
currentY = drawTravelEntry(info: travelInfo, y: currentY)
|
||
currentY += itemSpacing
|
||
hasContent = true
|
||
case .custom(let customInfo):
|
||
currentY = drawCustomEntry(info: customInfo, y: currentY)
|
||
currentY += itemSpacing
|
||
hasContent = true
|
||
}
|
||
}
|
||
|
||
// Rest day message if no content
|
||
if !hasContent {
|
||
let restAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.italicSystemFont(ofSize: 13),
|
||
.foregroundColor: textMuted
|
||
]
|
||
("Rest Day – Explore the city!" as NSString).draw(
|
||
at: CGPoint(x: margin + 12, y: currentY),
|
||
withAttributes: restAttributes
|
||
)
|
||
currentY += 28
|
||
}
|
||
|
||
return currentY
|
||
}
|
||
|
||
/// Determine primary city from items
|
||
private func determinePrimaryCity(from items: [ItineraryItem], games: [String: RichGame]) -> String? {
|
||
for item in items {
|
||
switch item.kind {
|
||
case .game(let gameId, let city):
|
||
// Prefer stadium city if available
|
||
if let richGame = games[gameId] {
|
||
return "\(richGame.stadium.city), \(richGame.stadium.state)"
|
||
}
|
||
return city
|
||
case .travel(let info):
|
||
return info.toCity
|
||
case .custom:
|
||
continue // Skip custom items for city detection
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// MARK: - Magazine-Style Item Drawing
|
||
|
||
/// Draw a simplified game entry: "Lakers vs. Celtics • 7:00 PM"
|
||
private func drawGameEntry(richGame: RichGame, y: CGFloat) -> CGFloat {
|
||
let currentY = y
|
||
|
||
// Sport color accent (left border)
|
||
let accentBarRect = CGRect(x: margin, y: currentY, width: 3, height: 36)
|
||
let sportColor = sportAccentColor(for: richGame.game.sport)
|
||
sportColor.setFill()
|
||
UIBezierPath(roundedRect: accentBarRect, cornerRadius: 1.5).fill()
|
||
|
||
// Teams: "Lakers vs. Celtics"
|
||
let teamsText = "\(richGame.awayTeam.name) vs. \(richGame.homeTeam.name)"
|
||
let teamsAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 14, weight: .semibold),
|
||
.foregroundColor: textPrimary
|
||
]
|
||
(teamsText as NSString).draw(at: CGPoint(x: margin + 14, y: currentY), withAttributes: teamsAttributes)
|
||
|
||
// Time: "7:00 PM EDT" (stadium local time with timezone indicator)
|
||
let timeText = richGame.localGameTime
|
||
let timeAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 13),
|
||
.foregroundColor: textSecondary
|
||
]
|
||
(timeText as NSString).draw(at: CGPoint(x: margin + 14, y: currentY + 20), withAttributes: timeAttributes)
|
||
|
||
// Venue name (subtle, after time)
|
||
let venueText = " • \(richGame.stadium.name)"
|
||
let venueAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 13),
|
||
.foregroundColor: textMuted
|
||
]
|
||
let timeSize = (timeText as NSString).size(withAttributes: timeAttributes)
|
||
(venueText as NSString).draw(
|
||
at: CGPoint(x: margin + 14 + timeSize.width, y: currentY + 20),
|
||
withAttributes: venueAttributes
|
||
)
|
||
|
||
return currentY + 40
|
||
}
|
||
|
||
/// Draw a travel entry: "→ 3h 45m • 220 mi to Phoenix"
|
||
private func drawTravelEntry(info: TravelInfo, y: CGFloat) -> CGFloat {
|
||
let currentY = y
|
||
|
||
// Arrow indicator
|
||
let arrowAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 12, weight: .medium),
|
||
.foregroundColor: textMuted
|
||
]
|
||
("→" as NSString).draw(at: CGPoint(x: margin + 4, y: currentY + 2), withAttributes: arrowAttributes)
|
||
|
||
// Build travel text: "3h 45m • 220 mi to Phoenix"
|
||
var travelParts: [String] = []
|
||
if !info.formattedDuration.isEmpty {
|
||
travelParts.append(info.formattedDuration)
|
||
}
|
||
if !info.formattedDistance.isEmpty {
|
||
// Convert to miles format
|
||
travelParts.append(info.formattedDistance)
|
||
}
|
||
travelParts.append("to \(info.toCity)")
|
||
|
||
let travelText = travelParts.joined(separator: " • ")
|
||
let travelAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 12),
|
||
.foregroundColor: textSecondary
|
||
]
|
||
(travelText as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 2), withAttributes: travelAttributes)
|
||
|
||
return currentY + 28
|
||
}
|
||
|
||
/// Draw a custom item entry: "🍽️ Lunch at Joe's Diner • 123 Main St"
|
||
private func drawCustomEntry(info: CustomInfo, y: CGFloat) -> CGFloat {
|
||
let currentY = y
|
||
|
||
// Icon
|
||
let iconAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 14)
|
||
]
|
||
(info.icon as NSString).draw(at: CGPoint(x: margin + 2, y: currentY), withAttributes: iconAttributes)
|
||
|
||
// Title
|
||
let titleAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 13, weight: .medium),
|
||
.foregroundColor: textPrimary
|
||
]
|
||
(info.title as NSString).draw(at: CGPoint(x: margin + 24, y: currentY), withAttributes: titleAttributes)
|
||
|
||
// Address (if present)
|
||
if let address = info.address {
|
||
let addressAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 11),
|
||
.foregroundColor: textMuted
|
||
]
|
||
(address as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 18), withAttributes: addressAttributes)
|
||
return currentY + 36
|
||
}
|
||
|
||
return currentY + 22
|
||
}
|
||
|
||
/// Draw a lodging entry: "🏨 Marriott Downtown • 123 Main St"
|
||
private func drawLodgingEntry(name: String, address: String?, y: CGFloat) -> CGFloat {
|
||
let currentY = y
|
||
|
||
// Hotel icon
|
||
let iconAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 14)
|
||
]
|
||
("🏨" as NSString).draw(at: CGPoint(x: margin + 2, y: currentY), withAttributes: iconAttributes)
|
||
|
||
// Name
|
||
let nameAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 13, weight: .medium),
|
||
.foregroundColor: textPrimary
|
||
]
|
||
(name as NSString).draw(at: CGPoint(x: margin + 24, y: currentY), withAttributes: nameAttributes)
|
||
|
||
// Address
|
||
if let address = address {
|
||
let addressAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 11),
|
||
.foregroundColor: textMuted
|
||
]
|
||
(address as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 18), withAttributes: addressAttributes)
|
||
return currentY + 36
|
||
}
|
||
|
||
return currentY + 22
|
||
}
|
||
|
||
private func estimateDayHeight(day: ItineraryDay, games: [String: RichGame]) -> CGFloat {
|
||
var height: CGFloat = 50 // Day header + city
|
||
|
||
// Games (simplified)
|
||
height += CGFloat(day.gameIds.count) * (44 + itemSpacing)
|
||
|
||
// Travel
|
||
if day.hasTravelSegment {
|
||
height += 36 + itemSpacing
|
||
}
|
||
|
||
// Rest day
|
||
if day.isRestDay && day.gameIds.isEmpty {
|
||
height += 28
|
||
}
|
||
|
||
return height
|
||
}
|
||
|
||
/// Draw a day using derived order (magazine-style)
|
||
private func drawDay(
|
||
context: UIGraphicsPDFRendererContext,
|
||
day: ItineraryDay,
|
||
games: [String: RichGame],
|
||
assets: PDFAssetPrefetcher.PrefetchedAssets?,
|
||
y: CGFloat
|
||
) -> CGFloat {
|
||
var currentY = y
|
||
|
||
// Day header: "Day 1 • Sat, Jan 18"
|
||
let dayHeaderAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 16, weight: .bold),
|
||
.foregroundColor: textPrimary
|
||
]
|
||
|
||
let formatter = DateFormatter()
|
||
formatter.dateFormat = "EEE, MMM d" // "Sat, Jan 18"
|
||
let dayHeaderText = "Day \(day.dayNumber) • \(formatter.string(from: day.date))"
|
||
(dayHeaderText as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: dayHeaderAttributes)
|
||
currentY += 28
|
||
|
||
// Location subtitle
|
||
if let city = day.primaryCity {
|
||
let locationAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 12),
|
||
.foregroundColor: textMuted
|
||
]
|
||
(city as NSString).draw(at: CGPoint(x: margin, y: currentY), withAttributes: locationAttributes)
|
||
currentY += 22
|
||
}
|
||
|
||
currentY += 8 // Space before items
|
||
|
||
// Games
|
||
for gameId in day.gameIds {
|
||
if let richGame = games[gameId] {
|
||
currentY = drawGameEntry(richGame: richGame, y: currentY)
|
||
currentY += itemSpacing
|
||
}
|
||
}
|
||
|
||
// Travel segments
|
||
for segment in day.travelSegments {
|
||
currentY = drawTravelSegmentEntry(segment: segment, y: currentY)
|
||
currentY += itemSpacing
|
||
}
|
||
|
||
// Rest day
|
||
if day.isRestDay && day.gameIds.isEmpty {
|
||
let restAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.italicSystemFont(ofSize: 13),
|
||
.foregroundColor: textMuted
|
||
]
|
||
("Rest Day – Explore the city!" as NSString).draw(
|
||
at: CGPoint(x: margin + 12, y: currentY),
|
||
withAttributes: restAttributes
|
||
)
|
||
currentY += 28
|
||
}
|
||
|
||
return currentY
|
||
}
|
||
|
||
/// Draw a travel segment entry (from TravelSegment, not TravelInfo)
|
||
private func drawTravelSegmentEntry(segment: TravelSegment, y: CGFloat) -> CGFloat {
|
||
let currentY = y
|
||
|
||
// Arrow indicator
|
||
let arrowAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 12, weight: .medium),
|
||
.foregroundColor: textMuted
|
||
]
|
||
("→" as NSString).draw(at: CGPoint(x: margin + 4, y: currentY + 2), withAttributes: arrowAttributes)
|
||
|
||
// Build travel text
|
||
let travelText = "\(segment.formattedDuration) • \(segment.formattedDistance) to \(segment.toLocation.name)"
|
||
let travelAttributes: [NSAttributedString.Key: Any] = [
|
||
.font: UIFont.systemFont(ofSize: 12),
|
||
.foregroundColor: textSecondary
|
||
]
|
||
(travelText as NSString).draw(at: CGPoint(x: margin + 24, y: currentY + 2), withAttributes: travelAttributes)
|
||
|
||
return currentY + 28
|
||
}
|
||
|
||
}
|
||
|
||
// 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()
|
||
|
||
/// Sanitize a string for use as a filename by removing invalid characters.
|
||
private func sanitizeFilename(_ name: String) -> String {
|
||
let invalidChars = CharacterSet(charactersIn: "/\\:*?\"<>|")
|
||
return name.components(separatedBy: invalidChars).joined(separator: "_")
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.prefix(255)
|
||
.description
|
||
}
|
||
|
||
/// Export trip to PDF with full prefetched assets
|
||
/// - Parameters:
|
||
/// - trip: The trip to export
|
||
/// - games: Dictionary of game IDs to RichGame objects
|
||
/// - itineraryItems: Optional custom itinerary items for user-specified ordering
|
||
/// - progressCallback: Optional callback for prefetch progress
|
||
func exportToPDF(
|
||
trip: Trip,
|
||
games: [String: RichGame],
|
||
itineraryItems: [ItineraryItem]? = nil,
|
||
progressCallback: (@Sendable (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 and custom itinerary order
|
||
let data = try await pdfGenerator.generatePDF(
|
||
for: trip,
|
||
games: games,
|
||
assets: assets,
|
||
itineraryItems: itineraryItems
|
||
)
|
||
|
||
// Save to temp file
|
||
let safeName = sanitizeFilename(trip.name)
|
||
let fileName = "\(safeName)_\(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],
|
||
itineraryItems: [ItineraryItem]? = nil
|
||
) async throws -> URL {
|
||
let data = try await pdfGenerator.generatePDF(
|
||
for: trip,
|
||
games: games,
|
||
assets: nil,
|
||
itineraryItems: itineraryItems
|
||
)
|
||
|
||
let safeName = sanitizeFilename(trip.name)
|
||
let fileName = "\(safeName)_\(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)
|
||
}
|
||
}
|