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 exportURL: 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 isSaved = false
|
||||
@State private var routePolylines: [MKPolyline] = []
|
||||
@@ -93,6 +95,64 @@ struct TripDetailView: View {
|
||||
.onAppear {
|
||||
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
|
||||
@@ -432,13 +492,22 @@ struct TripDetailView: View {
|
||||
// MARK: - Actions
|
||||
|
||||
private func exportPDF() async {
|
||||
isExporting = true
|
||||
exportProgress = nil
|
||||
|
||||
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
|
||||
showExportSheet = true
|
||||
} catch {
|
||||
print("Failed to export PDF: \(error)")
|
||||
}
|
||||
|
||||
isExporting = false
|
||||
}
|
||||
|
||||
private func shareTrip() async {
|
||||
|
||||
Reference in New Issue
Block a user