Files
Sportstime/SportsTime/Export/Services/POISearchService.swift
Trey t c976ae5cb3 Add POI category filters, delete item button, and fix itinerary persistence
- Expand POI categories from 5 to 7 (restaurant, bar, coffee, hotel, parking, attraction, entertainment)
- Add category filter chips with per-category API calls and caching
- Add delete button with confirmation dialog to Edit Item sheet
- Fix itinerary items not persisting: use LocalItineraryItem (SwiftData) as primary store with CloudKit sync as secondary, register model in schema

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 16:04:53 -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, @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: " ")
}
}