Files
Sportstime/SportsTime/Export/Services/POISearchService.swift
Trey t 045fcd9c07 Remove debug prints and fix build warnings
- 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>
2026-01-08 13:25:27 -06:00

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