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:
Trey t
2026-01-08 11:53:03 -06:00
parent bac9cad20b
commit fbb5ae683e
6 changed files with 1756 additions and 202 deletions

File diff suppressed because it is too large Load Diff

View 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)
}
}

View 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
}
}

View 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: " ")
}
}

View 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))
}
}
}

View File

@@ -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 {