// // 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, @unchecked Sendable { let id: UUID let name: String let category: POICategory let coordinate: CLLocationCoordinate2D let distanceMeters: Double let address: String? let mapItem: MKMapItem? 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 bar case coffee case hotel case parking case attraction case entertainment var displayName: String { switch self { case .restaurant: return "Restaurant" case .bar: return "Bar" case .coffee: return "Coffee" case .hotel: return "Hotel" case .parking: return "Parking" case .attraction: return "Attraction" case .entertainment: return "Entertainment" } } var iconName: String { switch self { case .restaurant: return "fork.knife" case .bar: return "wineglass.fill" case .coffee: return "cup.and.saucer.fill" case .hotel: return "bed.double.fill" case .parking: return "car.fill" case .attraction: return "star.fill" case .entertainment: return "theatermasks.fill" } } var searchQuery: String { switch self { case .restaurant: return "restaurants" case .bar: return "bars" case .coffee: return "coffee shops" case .hotel: return "hotels" case .parking: return "parking" case .attraction: return "tourist attractions" case .entertainment: return "entertainment" } } } // 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), mapItem: 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: " ") } }