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:
File diff suppressed because it is too large
Load Diff
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
180
SportsTime/Export/Services/PDFAssetPrefetcher.swift
Normal file
180
SportsTime/Export/Services/PDFAssetPrefetcher.swift
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
//
|
||||||
|
// PDFAssetPrefetcher.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Orchestrates parallel prefetching of all assets needed for PDF generation.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
actor PDFAssetPrefetcher {
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
struct PrefetchedAssets {
|
||||||
|
let routeMap: UIImage?
|
||||||
|
let cityMaps: [String: UIImage]
|
||||||
|
let teamLogos: [UUID: UIImage]
|
||||||
|
let stadiumPhotos: [UUID: UIImage]
|
||||||
|
let cityPOIs: [String: [POISearchService.POI]]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
routeMap == nil && cityMaps.isEmpty && teamLogos.isEmpty && stadiumPhotos.isEmpty && cityPOIs.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrefetchProgress {
|
||||||
|
var routeMapComplete: Bool = false
|
||||||
|
var cityMapsComplete: Bool = false
|
||||||
|
var logosComplete: Bool = false
|
||||||
|
var photosComplete: Bool = false
|
||||||
|
var poisComplete: Bool = false
|
||||||
|
|
||||||
|
var percentComplete: Double {
|
||||||
|
let completedSteps = [routeMapComplete, cityMapsComplete, logosComplete, photosComplete, poisComplete]
|
||||||
|
.filter { $0 }.count
|
||||||
|
return Double(completedSteps) / 5.0
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentStep: String {
|
||||||
|
if !routeMapComplete { return "Generating route map..." }
|
||||||
|
if !cityMapsComplete { return "Generating city maps..." }
|
||||||
|
if !logosComplete { return "Downloading team logos..." }
|
||||||
|
if !photosComplete { return "Downloading stadium photos..." }
|
||||||
|
if !poisComplete { return "Finding nearby attractions..." }
|
||||||
|
return "Complete"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private let mapService = MapSnapshotService()
|
||||||
|
private let imageService = RemoteImageService()
|
||||||
|
private let poiService = POISearchService()
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Prefetch all assets needed for PDF generation
|
||||||
|
/// - Parameters:
|
||||||
|
/// - trip: The trip to generate PDF for
|
||||||
|
/// - games: Map of game IDs to RichGame objects
|
||||||
|
/// - progressCallback: Optional callback for progress updates
|
||||||
|
/// - Returns: All prefetched assets
|
||||||
|
func prefetchAssets(
|
||||||
|
for trip: Trip,
|
||||||
|
games: [UUID: RichGame],
|
||||||
|
progressCallback: ((PrefetchProgress) async -> Void)? = nil
|
||||||
|
) async -> PrefetchedAssets {
|
||||||
|
var progress = PrefetchProgress()
|
||||||
|
|
||||||
|
// Collect unique teams and stadiums from games
|
||||||
|
var teams: [Team] = []
|
||||||
|
var stadiums: [Stadium] = []
|
||||||
|
var seenTeamIds: Set<UUID> = []
|
||||||
|
var seenStadiumIds: Set<UUID> = []
|
||||||
|
|
||||||
|
for (_, richGame) in games {
|
||||||
|
if !seenTeamIds.contains(richGame.homeTeam.id) {
|
||||||
|
teams.append(richGame.homeTeam)
|
||||||
|
seenTeamIds.insert(richGame.homeTeam.id)
|
||||||
|
}
|
||||||
|
if !seenTeamIds.contains(richGame.awayTeam.id) {
|
||||||
|
teams.append(richGame.awayTeam)
|
||||||
|
seenTeamIds.insert(richGame.awayTeam.id)
|
||||||
|
}
|
||||||
|
if !seenStadiumIds.contains(richGame.stadium.id) {
|
||||||
|
stadiums.append(richGame.stadium)
|
||||||
|
seenStadiumIds.insert(richGame.stadium.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run all fetches in parallel
|
||||||
|
async let routeMapTask = fetchRouteMap(stops: trip.stops)
|
||||||
|
async let cityMapsTask = fetchCityMaps(stops: trip.stops)
|
||||||
|
async let logosTask = imageService.fetchTeamLogos(teams: teams)
|
||||||
|
async let photosTask = imageService.fetchStadiumPhotos(stadiums: stadiums)
|
||||||
|
async let poisTask = poiService.findPOIsForCities(stops: trip.stops, limit: 5)
|
||||||
|
|
||||||
|
// Await each result and update progress
|
||||||
|
let routeMap = await routeMapTask
|
||||||
|
progress.routeMapComplete = true
|
||||||
|
await progressCallback?(progress)
|
||||||
|
|
||||||
|
let cityMaps = await cityMapsTask
|
||||||
|
progress.cityMapsComplete = true
|
||||||
|
await progressCallback?(progress)
|
||||||
|
|
||||||
|
let teamLogos = await logosTask
|
||||||
|
progress.logosComplete = true
|
||||||
|
await progressCallback?(progress)
|
||||||
|
|
||||||
|
let stadiumPhotos = await photosTask
|
||||||
|
progress.photosComplete = true
|
||||||
|
await progressCallback?(progress)
|
||||||
|
|
||||||
|
let cityPOIs = await poisTask
|
||||||
|
progress.poisComplete = true
|
||||||
|
await progressCallback?(progress)
|
||||||
|
|
||||||
|
print("[PDFAssetPrefetcher] Prefetch complete:")
|
||||||
|
print(" - Route map: \(routeMap != nil ? "OK" : "Failed")")
|
||||||
|
print(" - City maps: \(cityMaps.count) cities")
|
||||||
|
print(" - Team logos: \(teamLogos.count) logos")
|
||||||
|
print(" - Stadium photos: \(stadiumPhotos.count) photos")
|
||||||
|
print(" - POIs: \(cityPOIs.values.reduce(0) { $0 + $1.count }) total POIs")
|
||||||
|
|
||||||
|
return PrefetchedAssets(
|
||||||
|
routeMap: routeMap,
|
||||||
|
cityMaps: cityMaps,
|
||||||
|
teamLogos: teamLogos,
|
||||||
|
stadiumPhotos: stadiumPhotos,
|
||||||
|
cityPOIs: cityPOIs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func fetchRouteMap(stops: [TripStop]) async -> UIImage? {
|
||||||
|
do {
|
||||||
|
// PDF page is 612 wide, minus margins
|
||||||
|
let mapSize = CGSize(width: 512, height: 350)
|
||||||
|
return try await mapService.generateRouteMap(stops: stops, size: mapSize)
|
||||||
|
} catch {
|
||||||
|
print("[PDFAssetPrefetcher] Route map failed: \(error.localizedDescription)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchCityMaps(stops: [TripStop]) async -> [String: UIImage] {
|
||||||
|
var results: [String: UIImage] = [:]
|
||||||
|
let mapSize = CGSize(width: 250, height: 200)
|
||||||
|
|
||||||
|
await withTaskGroup(of: (String, UIImage?).self) { group in
|
||||||
|
// Deduplicate cities
|
||||||
|
var seenCities: Set<String> = []
|
||||||
|
for stop in stops {
|
||||||
|
guard !seenCities.contains(stop.city) else { continue }
|
||||||
|
seenCities.insert(stop.city)
|
||||||
|
|
||||||
|
group.addTask {
|
||||||
|
do {
|
||||||
|
let map = try await self.mapService.generateCityMap(stop: stop, size: mapSize)
|
||||||
|
return (stop.city, map)
|
||||||
|
} catch {
|
||||||
|
print("[PDFAssetPrefetcher] City map for \(stop.city) failed: \(error.localizedDescription)")
|
||||||
|
return (stop.city, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (city, map) in group {
|
||||||
|
if let map = map {
|
||||||
|
results[city] = map
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
246
SportsTime/Export/Services/POISearchService.swift
Normal file
246
SportsTime/Export/Services/POISearchService.swift
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
//
|
||||||
|
// POISearchService.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Searches for nearby points of interest using MapKit for PDF city spotlights.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import MapKit
|
||||||
|
import CoreLocation
|
||||||
|
|
||||||
|
actor POISearchService {
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
struct POI: Identifiable, Hashable {
|
||||||
|
let id: UUID
|
||||||
|
let name: String
|
||||||
|
let category: POICategory
|
||||||
|
let coordinate: CLLocationCoordinate2D
|
||||||
|
let distanceMeters: Double
|
||||||
|
let address: String?
|
||||||
|
|
||||||
|
var formattedDistance: String {
|
||||||
|
let miles = distanceMeters * 0.000621371
|
||||||
|
if miles < 0.1 {
|
||||||
|
let feet = distanceMeters * 3.28084
|
||||||
|
return String(format: "%.0f ft", feet)
|
||||||
|
} else {
|
||||||
|
return String(format: "%.1f mi", miles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hashable conformance for CLLocationCoordinate2D
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func == (lhs: POI, rhs: POI) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum POICategory: String, CaseIterable {
|
||||||
|
case restaurant
|
||||||
|
case attraction
|
||||||
|
case entertainment
|
||||||
|
case nightlife
|
||||||
|
case museum
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
switch self {
|
||||||
|
case .restaurant: return "Restaurant"
|
||||||
|
case .attraction: return "Attraction"
|
||||||
|
case .entertainment: return "Entertainment"
|
||||||
|
case .nightlife: return "Nightlife"
|
||||||
|
case .museum: return "Museum"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconName: String {
|
||||||
|
switch self {
|
||||||
|
case .restaurant: return "fork.knife"
|
||||||
|
case .attraction: return "star.fill"
|
||||||
|
case .entertainment: return "theatermasks.fill"
|
||||||
|
case .nightlife: return "moon.stars.fill"
|
||||||
|
case .museum: return "building.columns.fill"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var mkPointOfInterestCategory: MKPointOfInterestCategory {
|
||||||
|
switch self {
|
||||||
|
case .restaurant: return .restaurant
|
||||||
|
case .attraction: return .nationalPark
|
||||||
|
case .entertainment: return .theater
|
||||||
|
case .nightlife: return .nightlife
|
||||||
|
case .museum: return .museum
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var searchQuery: String {
|
||||||
|
switch self {
|
||||||
|
case .restaurant: return "restaurants"
|
||||||
|
case .attraction: return "tourist attractions"
|
||||||
|
case .entertainment: return "entertainment"
|
||||||
|
case .nightlife: return "bars nightlife"
|
||||||
|
case .museum: return "museums"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum POISearchError: Error, LocalizedError {
|
||||||
|
case searchFailed(String)
|
||||||
|
case noResults
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .searchFailed(let reason):
|
||||||
|
return "POI search failed: \(reason)"
|
||||||
|
case .noResults:
|
||||||
|
return "No points of interest found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Find nearby POIs for a city/stadium location
|
||||||
|
func findNearbyPOIs(
|
||||||
|
near coordinate: CLLocationCoordinate2D,
|
||||||
|
categories: [POICategory] = [.restaurant, .attraction, .entertainment],
|
||||||
|
radiusMeters: Double = 3000,
|
||||||
|
limitPerCategory: Int = 2
|
||||||
|
) async throws -> [POI] {
|
||||||
|
var allPOIs: [POI] = []
|
||||||
|
|
||||||
|
// Search each category in parallel
|
||||||
|
await withTaskGroup(of: [POI].self) { group in
|
||||||
|
for category in categories {
|
||||||
|
group.addTask {
|
||||||
|
do {
|
||||||
|
return try await self.searchCategory(
|
||||||
|
category,
|
||||||
|
near: coordinate,
|
||||||
|
radiusMeters: radiusMeters,
|
||||||
|
limit: limitPerCategory
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
print("[POISearchService] Search failed for \(category): \(error.localizedDescription)")
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await pois in group {
|
||||||
|
allPOIs.append(contentsOf: pois)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by distance
|
||||||
|
allPOIs.sort { $0.distanceMeters < $1.distanceMeters }
|
||||||
|
|
||||||
|
return allPOIs
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Find POIs for multiple cities (one search per city)
|
||||||
|
func findPOIsForCities(
|
||||||
|
stops: [TripStop],
|
||||||
|
categories: [POICategory] = [.restaurant, .attraction, .entertainment],
|
||||||
|
limit: Int = 5
|
||||||
|
) async -> [String: [POI]] {
|
||||||
|
var results: [String: [POI]] = [:]
|
||||||
|
|
||||||
|
await withTaskGroup(of: (String, [POI]).self) { group in
|
||||||
|
for stop in stops {
|
||||||
|
guard let coordinate = stop.coordinate else { continue }
|
||||||
|
|
||||||
|
group.addTask {
|
||||||
|
do {
|
||||||
|
let pois = try await self.findNearbyPOIs(
|
||||||
|
near: coordinate,
|
||||||
|
categories: categories,
|
||||||
|
limitPerCategory: 2
|
||||||
|
)
|
||||||
|
// Take top N overall
|
||||||
|
return (stop.city, Array(pois.prefix(limit)))
|
||||||
|
} catch {
|
||||||
|
print("[POISearchService] Failed for \(stop.city): \(error.localizedDescription)")
|
||||||
|
return (stop.city, [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (city, pois) in group {
|
||||||
|
if !pois.isEmpty {
|
||||||
|
results[city] = pois
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private func searchCategory(
|
||||||
|
_ category: POICategory,
|
||||||
|
near coordinate: CLLocationCoordinate2D,
|
||||||
|
radiusMeters: Double,
|
||||||
|
limit: Int
|
||||||
|
) async throws -> [POI] {
|
||||||
|
let request = MKLocalSearch.Request()
|
||||||
|
request.naturalLanguageQuery = category.searchQuery
|
||||||
|
request.region = MKCoordinateRegion(
|
||||||
|
center: coordinate,
|
||||||
|
latitudinalMeters: radiusMeters * 2,
|
||||||
|
longitudinalMeters: radiusMeters * 2
|
||||||
|
)
|
||||||
|
request.resultTypes = .pointOfInterest
|
||||||
|
|
||||||
|
let search = MKLocalSearch(request: request)
|
||||||
|
let response = try await search.start()
|
||||||
|
|
||||||
|
let referenceLocation = CLLocation(latitude: coordinate.latitude, longitude: coordinate.longitude)
|
||||||
|
|
||||||
|
let pois: [POI] = response.mapItems.prefix(limit).compactMap { item in
|
||||||
|
guard let name = item.name else { return nil }
|
||||||
|
|
||||||
|
let itemLocation = CLLocation(
|
||||||
|
latitude: item.placemark.coordinate.latitude,
|
||||||
|
longitude: item.placemark.coordinate.longitude
|
||||||
|
)
|
||||||
|
let distance = referenceLocation.distance(from: itemLocation)
|
||||||
|
|
||||||
|
// Only include POIs within radius
|
||||||
|
guard distance <= radiusMeters else { return nil }
|
||||||
|
|
||||||
|
return POI(
|
||||||
|
id: UUID(),
|
||||||
|
name: name,
|
||||||
|
category: category,
|
||||||
|
coordinate: item.placemark.coordinate,
|
||||||
|
distanceMeters: distance,
|
||||||
|
address: formatAddress(item.placemark)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pois
|
||||||
|
}
|
||||||
|
|
||||||
|
private func formatAddress(_ placemark: MKPlacemark) -> String? {
|
||||||
|
var components: [String] = []
|
||||||
|
|
||||||
|
if let subThoroughfare = placemark.subThoroughfare {
|
||||||
|
components.append(subThoroughfare)
|
||||||
|
}
|
||||||
|
if let thoroughfare = placemark.thoroughfare {
|
||||||
|
components.append(thoroughfare)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !components.isEmpty else { return nil }
|
||||||
|
return components.joined(separator: " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
179
SportsTime/Export/Services/RemoteImageService.swift
Normal file
179
SportsTime/Export/Services/RemoteImageService.swift
Normal file
@@ -0,0 +1,179 @@
|
|||||||
|
//
|
||||||
|
// RemoteImageService.swift
|
||||||
|
// SportsTime
|
||||||
|
//
|
||||||
|
// Downloads and caches remote images for PDF export (team logos, stadium photos).
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
actor RemoteImageService {
|
||||||
|
|
||||||
|
// MARK: - Errors
|
||||||
|
|
||||||
|
enum ImageFetchError: Error, LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case downloadFailed(String)
|
||||||
|
case invalidImageData
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL:
|
||||||
|
return "Invalid image URL"
|
||||||
|
case .downloadFailed(let reason):
|
||||||
|
return "Image download failed: \(reason)"
|
||||||
|
case .invalidImageData:
|
||||||
|
return "Downloaded data is not a valid image"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Properties
|
||||||
|
|
||||||
|
private let urlSession: URLSession
|
||||||
|
private var imageCache: [URL: UIImage] = [:]
|
||||||
|
|
||||||
|
// MARK: - Init
|
||||||
|
|
||||||
|
init() {
|
||||||
|
// Configure URLSession with caching
|
||||||
|
let config = URLSessionConfiguration.default
|
||||||
|
config.requestCachePolicy = .returnCacheDataElseLoad
|
||||||
|
config.urlCache = URLCache(
|
||||||
|
memoryCapacity: 50 * 1024 * 1024, // 50 MB memory
|
||||||
|
diskCapacity: 100 * 1024 * 1024, // 100 MB disk
|
||||||
|
diskPath: "ImageCache"
|
||||||
|
)
|
||||||
|
config.timeoutIntervalForRequest = 15
|
||||||
|
config.timeoutIntervalForResource = 30
|
||||||
|
|
||||||
|
self.urlSession = URLSession(configuration: config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Public Methods
|
||||||
|
|
||||||
|
/// Fetch a single image from URL
|
||||||
|
func fetchImage(from url: URL) async throws -> UIImage {
|
||||||
|
// Check cache first
|
||||||
|
if let cached = imageCache[url] {
|
||||||
|
return cached
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download image
|
||||||
|
let (data, response) = try await urlSession.data(from: url)
|
||||||
|
|
||||||
|
// Validate response
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
(200...299).contains(httpResponse.statusCode) else {
|
||||||
|
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||||
|
throw ImageFetchError.downloadFailed("HTTP \(statusCode)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create image
|
||||||
|
guard let image = UIImage(data: data) else {
|
||||||
|
throw ImageFetchError.invalidImageData
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scale image for PDF (max 400pt width to keep file size reasonable)
|
||||||
|
let scaledImage = scaleImage(image, maxWidth: 400)
|
||||||
|
|
||||||
|
// Cache it
|
||||||
|
imageCache[url] = scaledImage
|
||||||
|
|
||||||
|
return scaledImage
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Batch fetch multiple images in parallel
|
||||||
|
func fetchImages(from urls: [URL]) async -> [URL: UIImage] {
|
||||||
|
var results: [URL: UIImage] = [:]
|
||||||
|
|
||||||
|
await withTaskGroup(of: (URL, UIImage?).self) { group in
|
||||||
|
for url in urls {
|
||||||
|
group.addTask {
|
||||||
|
do {
|
||||||
|
let image = try await self.fetchImage(from: url)
|
||||||
|
return (url, image)
|
||||||
|
} catch {
|
||||||
|
print("[RemoteImageService] Failed to fetch \(url): \(error.localizedDescription)")
|
||||||
|
return (url, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for await (url, image) in group {
|
||||||
|
if let image = image {
|
||||||
|
results[url] = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch team logos by team ID
|
||||||
|
func fetchTeamLogos(teams: [Team]) async -> [UUID: UIImage] {
|
||||||
|
let urlToTeam: [URL: UUID] = Dictionary(
|
||||||
|
uniqueKeysWithValues: teams.compactMap { team in
|
||||||
|
guard let logoURL = team.logoURL else { return nil }
|
||||||
|
return (logoURL, team.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let images = await fetchImages(from: Array(urlToTeam.keys))
|
||||||
|
|
||||||
|
var result: [UUID: UIImage] = [:]
|
||||||
|
for (url, image) in images {
|
||||||
|
if let teamId = urlToTeam[url] {
|
||||||
|
result[teamId] = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch stadium photos by stadium ID
|
||||||
|
func fetchStadiumPhotos(stadiums: [Stadium]) async -> [UUID: UIImage] {
|
||||||
|
let urlToStadium: [URL: UUID] = Dictionary(
|
||||||
|
uniqueKeysWithValues: stadiums.compactMap { stadium in
|
||||||
|
guard let imageURL = stadium.imageURL else { return nil }
|
||||||
|
return (imageURL, stadium.id)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
let images = await fetchImages(from: Array(urlToStadium.keys))
|
||||||
|
|
||||||
|
var result: [UUID: UIImage] = [:]
|
||||||
|
for (url, image) in images {
|
||||||
|
if let stadiumId = urlToStadium[url] {
|
||||||
|
result[stadiumId] = image
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear the in-memory cache
|
||||||
|
func clearCache() {
|
||||||
|
imageCache.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
/// Scale image to max width while maintaining aspect ratio
|
||||||
|
private func scaleImage(_ image: UIImage, maxWidth: CGFloat) -> UIImage {
|
||||||
|
let currentWidth = image.size.width
|
||||||
|
guard currentWidth > maxWidth else { return image }
|
||||||
|
|
||||||
|
let scale = maxWidth / currentWidth
|
||||||
|
let newSize = CGSize(
|
||||||
|
width: image.size.width * scale,
|
||||||
|
height: image.size.height * scale
|
||||||
|
)
|
||||||
|
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: newSize)
|
||||||
|
return renderer.image { _ in
|
||||||
|
image.draw(in: CGRect(origin: .zero, size: newSize))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,8 @@ struct TripDetailView: View {
|
|||||||
@State private var showShareSheet = false
|
@State private var showShareSheet = false
|
||||||
@State private var exportURL: URL?
|
@State private var exportURL: URL?
|
||||||
@State private var shareURL: URL?
|
@State private var shareURL: URL?
|
||||||
|
@State private var isExporting = false
|
||||||
|
@State private var exportProgress: PDFAssetPrefetcher.PrefetchProgress?
|
||||||
@State private var mapCameraPosition: MapCameraPosition = .automatic
|
@State private var mapCameraPosition: MapCameraPosition = .automatic
|
||||||
@State private var isSaved = false
|
@State private var isSaved = false
|
||||||
@State private var routePolylines: [MKPolyline] = []
|
@State private var routePolylines: [MKPolyline] = []
|
||||||
@@ -93,6 +95,64 @@ struct TripDetailView: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
checkIfSaved()
|
checkIfSaved()
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if isExporting {
|
||||||
|
exportProgressOverlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Export Progress Overlay
|
||||||
|
|
||||||
|
private var exportProgressOverlay: some View {
|
||||||
|
ZStack {
|
||||||
|
// Background dimmer
|
||||||
|
Color.black.opacity(0.6)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
// Progress card
|
||||||
|
VStack(spacing: Theme.Spacing.lg) {
|
||||||
|
// Progress ring
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.stroke(Theme.cardBackgroundElevated(colorScheme), lineWidth: 8)
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.trim(from: 0, to: exportProgress?.percentComplete ?? 0)
|
||||||
|
.stroke(Theme.warmOrange, style: StrokeStyle(lineWidth: 8, lineCap: .round))
|
||||||
|
.frame(width: 80, height: 80)
|
||||||
|
.rotationEffect(.degrees(-90))
|
||||||
|
.animation(.easeInOut(duration: 0.3), value: exportProgress?.percentComplete)
|
||||||
|
|
||||||
|
Image(systemName: "doc.fill")
|
||||||
|
.font(.system(size: 24))
|
||||||
|
.foregroundStyle(Theme.warmOrange)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: Theme.Spacing.xs) {
|
||||||
|
Text("Creating PDF")
|
||||||
|
.font(.system(size: Theme.FontSize.cardTitle, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.textPrimary(colorScheme))
|
||||||
|
|
||||||
|
Text(exportProgress?.currentStep ?? "Preparing...")
|
||||||
|
.font(.system(size: Theme.FontSize.caption))
|
||||||
|
.foregroundStyle(Theme.textSecondary(colorScheme))
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
|
if let progress = exportProgress {
|
||||||
|
Text("\(Int(progress.percentComplete * 100))%")
|
||||||
|
.font(.system(size: Theme.FontSize.micro, weight: .medium, design: .monospaced))
|
||||||
|
.foregroundStyle(Theme.textMuted(colorScheme))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(Theme.Spacing.xl)
|
||||||
|
.background(Theme.cardBackground(colorScheme))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: Theme.CornerRadius.large))
|
||||||
|
.shadow(color: .black.opacity(0.3), radius: 20, y: 10)
|
||||||
|
}
|
||||||
|
.transition(.opacity)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Hero Map Section
|
// MARK: - Hero Map Section
|
||||||
@@ -432,13 +492,22 @@ struct TripDetailView: View {
|
|||||||
// MARK: - Actions
|
// MARK: - Actions
|
||||||
|
|
||||||
private func exportPDF() async {
|
private func exportPDF() async {
|
||||||
|
isExporting = true
|
||||||
|
exportProgress = nil
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let url = try await exportService.exportToPDF(trip: trip, games: games)
|
let url = try await exportService.exportToPDF(trip: trip, games: games) { progress in
|
||||||
|
await MainActor.run {
|
||||||
|
self.exportProgress = progress
|
||||||
|
}
|
||||||
|
}
|
||||||
exportURL = url
|
exportURL = url
|
||||||
showExportSheet = true
|
showExportSheet = true
|
||||||
} catch {
|
} catch {
|
||||||
print("Failed to export PDF: \(error)")
|
print("Failed to export PDF: \(error)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isExporting = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private func shareTrip() async {
|
private func shareTrip() async {
|
||||||
|
|||||||
Reference in New Issue
Block a user