- Remove all print statements from planning engine, data providers, and PDF generation - Fix deprecated CLGeocoder usage with MKLocalSearch for iOS 26 - Fix Swift 6 actor isolation by converting PDFGenerator/ExportService to @MainActor - Add @retroactive to CLLocationCoordinate2D protocol conformances - Fix unused variable warnings in GameDAGRouter and scenario planners - Remove unreachable catch blocks in SettingsViewModel 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
243 lines
7.2 KiB
Swift
243 lines
7.2 KiB
Swift
//
|
|
// 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 {
|
|
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 {
|
|
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 itemCoordinate = item.location.coordinate
|
|
let distance = referenceLocation.distance(from: item.location)
|
|
|
|
// Only include POIs within radius
|
|
guard distance <= radiusMeters else { return nil }
|
|
|
|
return POI(
|
|
id: UUID(),
|
|
name: name,
|
|
category: category,
|
|
coordinate: itemCoordinate,
|
|
distanceMeters: distance,
|
|
address: formatAddress(item)
|
|
)
|
|
}
|
|
|
|
return pois
|
|
}
|
|
|
|
@available(iOS, deprecated: 26.0, message: "Uses placemark for address formatting")
|
|
private func formatAddress(_ item: MKMapItem) -> String? {
|
|
var components: [String] = []
|
|
|
|
if let subThoroughfare = item.placemark.subThoroughfare {
|
|
components.append(subThoroughfare)
|
|
}
|
|
if let thoroughfare = item.placemark.thoroughfare {
|
|
components.append(thoroughfare)
|
|
}
|
|
|
|
guard !components.isEmpty else { return nil }
|
|
return components.joined(separator: " ")
|
|
}
|
|
}
|