Enhance PDF export with maps, images, and progress UI
- Add MapSnapshotService for route maps and city maps using MKMapSnapshotter - Add RemoteImageService for team logos and stadium photos with caching - Add POISearchService for nearby attractions using MKLocalSearch - Add PDFAssetPrefetcher to orchestrate parallel asset fetching - Rewrite PDFGenerator with rich page layouts: cover, route overview, day-by-day itinerary, city spotlights, and summary pages - Add export progress overlay in TripDetailView with animated progress ring 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
291
SportsTime/Export/Services/MapSnapshotService.swift
Normal file
291
SportsTime/Export/Services/MapSnapshotService.swift
Normal file
@@ -0,0 +1,291 @@
|
||||
//
|
||||
// MapSnapshotService.swift
|
||||
// SportsTime
|
||||
//
|
||||
// Generates static map images for PDF export using MKMapSnapshotter.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MapKit
|
||||
import UIKit
|
||||
|
||||
actor MapSnapshotService {
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum MapSnapshotError: Error, LocalizedError {
|
||||
case noStops
|
||||
case snapshotFailed(String)
|
||||
case invalidCoordinates
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noStops:
|
||||
return "No stops provided for map generation"
|
||||
case .snapshotFailed(let reason):
|
||||
return "Map snapshot failed: \(reason)"
|
||||
case .invalidCoordinates:
|
||||
return "Invalid coordinates for map region"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Route Map
|
||||
|
||||
/// Generate a full route map showing all stops with route line
|
||||
func generateRouteMap(
|
||||
stops: [TripStop],
|
||||
size: CGSize,
|
||||
routeColor: UIColor = UIColor(red: 0.12, green: 0.23, blue: 0.54, alpha: 1.0)
|
||||
) async throws -> UIImage {
|
||||
guard !stops.isEmpty else {
|
||||
throw MapSnapshotError.noStops
|
||||
}
|
||||
|
||||
let coordinates = stops.compactMap { $0.coordinate }
|
||||
guard !coordinates.isEmpty else {
|
||||
throw MapSnapshotError.invalidCoordinates
|
||||
}
|
||||
|
||||
// Calculate region to fit all stops with padding
|
||||
let region = regionToFit(coordinates: coordinates, paddingPercent: 0.2)
|
||||
|
||||
// Configure snapshotter
|
||||
let options = MKMapSnapshotter.Options()
|
||||
options.region = region
|
||||
options.size = size
|
||||
options.mapType = .standard
|
||||
options.showsBuildings = false
|
||||
options.pointOfInterestFilter = .excludingAll
|
||||
|
||||
// Generate snapshot
|
||||
let snapshotter = MKMapSnapshotter(options: options)
|
||||
let snapshot = try await snapshotter.start()
|
||||
|
||||
// Draw route and markers on snapshot
|
||||
let image = drawRouteOverlay(
|
||||
on: snapshot,
|
||||
coordinates: coordinates,
|
||||
stops: stops,
|
||||
routeColor: routeColor,
|
||||
size: size
|
||||
)
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
// MARK: - City Map
|
||||
|
||||
/// Generate a city-level map showing stadium location
|
||||
func generateCityMap(
|
||||
stop: TripStop,
|
||||
size: CGSize,
|
||||
radiusMeters: Double = 5000
|
||||
) async throws -> UIImage {
|
||||
guard let coordinate = stop.coordinate else {
|
||||
throw MapSnapshotError.invalidCoordinates
|
||||
}
|
||||
|
||||
// Create region centered on stadium
|
||||
let region = MKCoordinateRegion(
|
||||
center: coordinate,
|
||||
latitudinalMeters: radiusMeters * 2,
|
||||
longitudinalMeters: radiusMeters * 2
|
||||
)
|
||||
|
||||
// Configure snapshotter
|
||||
let options = MKMapSnapshotter.Options()
|
||||
options.region = region
|
||||
options.size = size
|
||||
options.mapType = .standard
|
||||
options.showsBuildings = true
|
||||
options.pointOfInterestFilter = .includingAll
|
||||
|
||||
// Generate snapshot
|
||||
let snapshotter = MKMapSnapshotter(options: options)
|
||||
let snapshot = try await snapshotter.start()
|
||||
|
||||
// Draw stadium marker
|
||||
let image = drawStadiumMarker(
|
||||
on: snapshot,
|
||||
coordinate: coordinate,
|
||||
cityName: stop.city,
|
||||
size: size
|
||||
)
|
||||
|
||||
return image
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Calculate a region that fits all coordinates with padding
|
||||
private func regionToFit(coordinates: [CLLocationCoordinate2D], paddingPercent: Double) -> MKCoordinateRegion {
|
||||
var minLat = coordinates[0].latitude
|
||||
var maxLat = coordinates[0].latitude
|
||||
var minLon = coordinates[0].longitude
|
||||
var maxLon = coordinates[0].longitude
|
||||
|
||||
for coord in coordinates {
|
||||
minLat = min(minLat, coord.latitude)
|
||||
maxLat = max(maxLat, coord.latitude)
|
||||
minLon = min(minLon, coord.longitude)
|
||||
maxLon = max(maxLon, coord.longitude)
|
||||
}
|
||||
|
||||
let latSpan = (maxLat - minLat) * (1 + paddingPercent)
|
||||
let lonSpan = (maxLon - minLon) * (1 + paddingPercent)
|
||||
|
||||
let center = CLLocationCoordinate2D(
|
||||
latitude: (minLat + maxLat) / 2,
|
||||
longitude: (minLon + maxLon) / 2
|
||||
)
|
||||
|
||||
let span = MKCoordinateSpan(
|
||||
latitudeDelta: max(latSpan, 0.5),
|
||||
longitudeDelta: max(lonSpan, 0.5)
|
||||
)
|
||||
|
||||
return MKCoordinateRegion(center: center, span: span)
|
||||
}
|
||||
|
||||
/// Draw route line and numbered markers on snapshot
|
||||
private func drawRouteOverlay(
|
||||
on snapshot: MKMapSnapshotter.Snapshot,
|
||||
coordinates: [CLLocationCoordinate2D],
|
||||
stops: [TripStop],
|
||||
routeColor: UIColor,
|
||||
size: CGSize
|
||||
) -> UIImage {
|
||||
UIGraphicsBeginImageContextWithOptions(size, true, 0)
|
||||
defer { UIGraphicsEndImageContext() }
|
||||
|
||||
// Draw base map
|
||||
snapshot.image.draw(at: .zero)
|
||||
|
||||
guard let context = UIGraphicsGetCurrentContext() else {
|
||||
return snapshot.image
|
||||
}
|
||||
|
||||
// Draw route line connecting stops
|
||||
if coordinates.count > 1 {
|
||||
context.setStrokeColor(routeColor.cgColor)
|
||||
context.setLineWidth(3.0)
|
||||
context.setLineCap(.round)
|
||||
context.setLineJoin(.round)
|
||||
|
||||
let points = coordinates.map { snapshot.point(for: $0) }
|
||||
context.move(to: points[0])
|
||||
for i in 1..<points.count {
|
||||
context.addLine(to: points[i])
|
||||
}
|
||||
context.strokePath()
|
||||
}
|
||||
|
||||
// Draw numbered markers for each stop
|
||||
for (index, coordinate) in coordinates.enumerated() {
|
||||
let point = snapshot.point(for: coordinate)
|
||||
drawNumberedMarker(
|
||||
at: point,
|
||||
number: index + 1,
|
||||
color: routeColor,
|
||||
in: context
|
||||
)
|
||||
}
|
||||
|
||||
return UIGraphicsGetImageFromCurrentImageContext() ?? snapshot.image
|
||||
}
|
||||
|
||||
/// Draw stadium marker on city map
|
||||
private func drawStadiumMarker(
|
||||
on snapshot: MKMapSnapshotter.Snapshot,
|
||||
coordinate: CLLocationCoordinate2D,
|
||||
cityName: String,
|
||||
size: CGSize
|
||||
) -> UIImage {
|
||||
UIGraphicsBeginImageContextWithOptions(size, true, 0)
|
||||
defer { UIGraphicsEndImageContext() }
|
||||
|
||||
// Draw base map
|
||||
snapshot.image.draw(at: .zero)
|
||||
|
||||
guard let context = UIGraphicsGetCurrentContext() else {
|
||||
return snapshot.image
|
||||
}
|
||||
|
||||
let point = snapshot.point(for: coordinate)
|
||||
|
||||
// Draw stadium pin
|
||||
let pinSize: CGFloat = 30
|
||||
let pinRect = CGRect(
|
||||
x: point.x - pinSize / 2,
|
||||
y: point.y - pinSize,
|
||||
width: pinSize,
|
||||
height: pinSize
|
||||
)
|
||||
|
||||
// Draw pin shadow
|
||||
context.saveGState()
|
||||
context.setShadow(offset: CGSize(width: 0, height: 2), blur: 4, color: UIColor.black.withAlphaComponent(0.3).cgColor)
|
||||
|
||||
// Draw pin body (red marker)
|
||||
context.setFillColor(UIColor.systemRed.cgColor)
|
||||
context.fillEllipse(in: pinRect.insetBy(dx: 2, dy: 2))
|
||||
|
||||
// Draw pin center (white dot)
|
||||
context.setFillColor(UIColor.white.cgColor)
|
||||
let centerRect = CGRect(
|
||||
x: point.x - 5,
|
||||
y: point.y - pinSize / 2 - 5,
|
||||
width: 10,
|
||||
height: 10
|
||||
)
|
||||
context.fillEllipse(in: centerRect)
|
||||
|
||||
context.restoreGState()
|
||||
|
||||
return UIGraphicsGetImageFromCurrentImageContext() ?? snapshot.image
|
||||
}
|
||||
|
||||
/// Draw a numbered circular marker
|
||||
private func drawNumberedMarker(
|
||||
at point: CGPoint,
|
||||
number: Int,
|
||||
color: UIColor,
|
||||
in context: CGContext
|
||||
) {
|
||||
let radius: CGFloat = 14
|
||||
let rect = CGRect(
|
||||
x: point.x - radius,
|
||||
y: point.y - radius,
|
||||
width: radius * 2,
|
||||
height: radius * 2
|
||||
)
|
||||
|
||||
// Draw white outline
|
||||
context.saveGState()
|
||||
context.setShadow(offset: CGSize(width: 0, height: 1), blur: 2, color: UIColor.black.withAlphaComponent(0.3).cgColor)
|
||||
context.setFillColor(UIColor.white.cgColor)
|
||||
context.fillEllipse(in: rect.insetBy(dx: -2, dy: -2))
|
||||
context.restoreGState()
|
||||
|
||||
// Draw colored circle
|
||||
context.setFillColor(color.cgColor)
|
||||
context.fillEllipse(in: rect)
|
||||
|
||||
// Draw number text
|
||||
let numberString = "\(number)" as NSString
|
||||
let attributes: [NSAttributedString.Key: Any] = [
|
||||
.font: UIFont.boldSystemFont(ofSize: 12),
|
||||
.foregroundColor: UIColor.white
|
||||
]
|
||||
|
||||
let textSize = numberString.size(withAttributes: attributes)
|
||||
let textRect = CGRect(
|
||||
x: point.x - textSize.width / 2,
|
||||
y: point.y - textSize.height / 2,
|
||||
width: textSize.width,
|
||||
height: textSize.height
|
||||
)
|
||||
numberString.draw(in: textRect, withAttributes: attributes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user