import Foundation #if os(iOS) import CoreLocation #endif @MainActor final class LocationService: NSObject { typealias LocationResult = Result<(Double, Double), LocationError> static let shared = LocationService() #if os(iOS) enum LocationError: Error { case servicesDisabled case denied case unavailable } #else enum LocationError: Error { case denied case unavailable } #endif #if os(iOS) private let manager = CLLocationManager() private var completion: ((LocationResult) -> Void)? #endif override init() { super.init() #if os(iOS) manager.delegate = self manager.desiredAccuracy = kCLLocationAccuracyHundredMeters #endif } func requestLocation(_ completion: @escaping (LocationResult) -> Void) { #if os(iOS) self.completion = completion handleAuthorizationStatus(manager.authorizationStatus) #else let defaults = UserDefaults.standard if defaults.bool(forKey: "location_permission_denied") { completion(.failure(.denied)) return } guard let latRaw = defaults.string(forKey: "last_location_lat"), let lngRaw = defaults.string(forKey: "last_location_lng"), let lat = Double(latRaw), let lng = Double(lngRaw) else { completion(.failure(.unavailable)) return } completion(.success((lat, lng))) #endif } func cachedLocation() -> (Double, Double)? { #if os(iOS) guard let location = manager.location else { return nil } return (location.coordinate.latitude, location.coordinate.longitude) #else let defaults = UserDefaults.standard guard let latRaw = defaults.string(forKey: "last_location_lat"), let lngRaw = defaults.string(forKey: "last_location_lng"), let lat = Double(latRaw), let lng = Double(lngRaw) else { return nil } return (lat, lng) #endif } /// Forward-geocodes a street address into coordinates. Used as a fallback /// when a saved CustomerAddress has no lat/long (e.g. the CEP lookup at /// creation time didn't return coordinates) — without this, delivery fee /// validation silently can't distinguish that address from any other. static func geocodeAddress( street: String?, number: String?, neighborhood: String?, city: String?, state: String?, zip: String? ) async -> (Double, Double)? { #if os(iOS) let parts = [street, number, neighborhood, city, state, zip] .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { $0.isEmpty == false } guard parts.isEmpty == false else { return nil } let fullAddress = parts.joined(separator: ", ") return await withCheckedContinuation { continuation in CLGeocoder().geocodeAddressString(fullAddress) { placemarks, error in guard error == nil, let coordinate = placemarks?.first?.location?.coordinate else { continuation.resume(returning: nil) return } continuation.resume(returning: (coordinate.latitude, coordinate.longitude)) } } #else return nil #endif } func requestLocationAsync(timeoutSeconds: TimeInterval = 8) async -> (Double, Double)? { await withCheckedContinuation { continuation in var hasResumed = false func resumeOnce(_ value: (Double, Double)?) { guard hasResumed == false else { return } hasResumed = true continuation.resume(returning: value) } requestLocation { result in switch result { case .success(let coordinate): resumeOnce(coordinate) case .failure: resumeOnce(nil) } } DispatchQueue.main.asyncAfter(deadline: .now() + timeoutSeconds) { resumeOnce(nil) } } } } #if os(iOS) extension LocationService: @preconcurrency CLLocationManagerDelegate { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { handleAuthorizationStatus(manager.authorizationStatus) } func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { guard let location = locations.first else { return } completion?(.success((location.coordinate.latitude, location.coordinate.longitude))) completion = nil } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { completion?(.failure(.unavailable)) completion = nil } private func handleAuthorizationStatus(_ status: CLAuthorizationStatus) { switch status { case .notDetermined: manager.requestWhenInUseAuthorization() case .authorizedAlways, .authorizedWhenInUse: manager.requestLocation() case .denied, .restricted: completion?(.failure(.denied)) completion = nil @unknown default: completion?(.failure(.unavailable)) completion = nil } } } #endif