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:
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: " ")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user