238 lines
7.0 KiB
Swift
238 lines
7.0 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, @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
|
|
}
|
|
|
|
private func formatAddress(_ item: MKMapItem) -> String? {
|
|
if let shortAddress = item.address?.shortAddress, !shortAddress.isEmpty {
|
|
return shortAddress
|
|
}
|
|
if let fullAddress = item.address?.fullAddress, !fullAddress.isEmpty {
|
|
return fullAddress
|
|
}
|
|
return nil
|
|
}
|
|
}
|