chore: wire in location services
Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public struct Coordinate: Sendable, Hashable, Codable {
|
||||
public let latitude: Double
|
||||
public let longitude: Double
|
||||
|
||||
public init(latitude: Double, longitude: Double) {
|
||||
self.latitude = latitude
|
||||
self.longitude = longitude
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
public struct CurrentLocation: Sendable, Hashable {
|
||||
public let coordinate: Coordinate
|
||||
public let name: String
|
||||
|
||||
public init(coordinate: Coordinate, name: String) {
|
||||
self.coordinate = coordinate
|
||||
self.name = name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
public enum LocationServiceError: Error, Sendable, Hashable {
|
||||
case authorizationDenied
|
||||
case locationUnavailable(reason: String)
|
||||
case geocodingFailed(reason: String)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
|
||||
public protocol LocationService: Sendable {
|
||||
func requestAuthorization() async
|
||||
func getCurrentLocation() async throws -> CurrentLocation
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
import CoreLocation
|
||||
import TempestasDomain
|
||||
|
||||
@MainActor
|
||||
public final class DefaultLocationService: NSObject, LocationService {
|
||||
private let manager = CLLocationManager()
|
||||
private let geocoder = CLGeocoder()
|
||||
|
||||
private var authorizationContinuation: CheckedContinuation<Void, Never>?
|
||||
private var locationContinuation: CheckedContinuation<CLLocation, Error>?
|
||||
|
||||
private var authorizationTask: Task<Void, Never>?
|
||||
private var locationTask: Task<CurrentLocation, Error>?
|
||||
|
||||
override public init() {
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
}
|
||||
|
||||
public func requestAuthorization() async {
|
||||
guard manager.authorizationStatus == .notDetermined else { return }
|
||||
|
||||
if let authorizationTask {
|
||||
return await authorizationTask.value
|
||||
}
|
||||
|
||||
let task: Task<Void, Never> = Task { @MainActor in
|
||||
await self.awaitAuthorizationChange()
|
||||
}
|
||||
|
||||
authorizationTask = task
|
||||
defer { authorizationTask = nil }
|
||||
|
||||
await withTaskCancellationHandler {
|
||||
await task.value
|
||||
} onCancel: {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
public func getCurrentLocation() async throws -> CurrentLocation {
|
||||
if let locationTask {
|
||||
return try await locationTask.value
|
||||
}
|
||||
|
||||
let task: Task<CurrentLocation, Error> = Task { @MainActor in
|
||||
try await self.fetchCurrentLocation()
|
||||
}
|
||||
|
||||
locationTask = task
|
||||
defer { locationTask = nil }
|
||||
|
||||
return try await withTaskCancellationHandler {
|
||||
try await task.value
|
||||
} onCancel: {
|
||||
task.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private func fetchCurrentLocation() async throws -> CurrentLocation {
|
||||
try await ensureAuthorized()
|
||||
|
||||
let location = try await requestLocationOnce()
|
||||
let name = await resolveName(for: location)
|
||||
|
||||
return CurrentLocation(
|
||||
coordinate: Coordinate(
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude
|
||||
),
|
||||
name: name
|
||||
)
|
||||
}
|
||||
|
||||
private func ensureAuthorized() async throws {
|
||||
await requestAuthorization()
|
||||
|
||||
switch manager.authorizationStatus {
|
||||
case .authorizedAlways:
|
||||
return
|
||||
case .notDetermined, .denied, .restricted:
|
||||
throw LocationServiceError.authorizationDenied
|
||||
@unknown default:
|
||||
throw LocationServiceError.authorizationDenied
|
||||
}
|
||||
}
|
||||
|
||||
private func awaitAuthorizationChange() async {
|
||||
await withTaskCancellationHandler {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.authorizationContinuation = continuation
|
||||
self.manager.requestWhenInUseAuthorization()
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor in
|
||||
self.cancelPendingAuthorizationWait()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPendingAuthorizationWait() {
|
||||
guard let authorizationContinuation else { return }
|
||||
self.authorizationContinuation = nil
|
||||
authorizationContinuation.resume()
|
||||
}
|
||||
|
||||
private func requestLocationOnce() async throws -> CLLocation {
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { continuation in
|
||||
self.locationContinuation = continuation
|
||||
self.manager.requestLocation()
|
||||
}
|
||||
} onCancel: {
|
||||
Task { @MainActor in
|
||||
self.cancelPendingLocationRequest()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelPendingLocationRequest() {
|
||||
guard let locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
manager.stopUpdatingLocation()
|
||||
locationContinuation.resume(throwing: CancellationError())
|
||||
}
|
||||
|
||||
private func resolveName(for location: CLLocation) async -> String {
|
||||
guard let placemarks = try? await geocoder.reverseGeocodeLocation(location),
|
||||
let placemark = placemarks.first else {
|
||||
return Self.fallbackName(for: location)
|
||||
}
|
||||
|
||||
return placemark.locality ?? placemark.name ?? Self.fallbackName(for: location)
|
||||
}
|
||||
|
||||
private static func fallbackName(for location: CLLocation) -> String {
|
||||
String(format: "%.4f, %.4f", location.coordinate.latitude, location.coordinate.longitude)
|
||||
}
|
||||
}
|
||||
|
||||
extension DefaultLocationService: @MainActor CLLocationManagerDelegate {
|
||||
public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
guard let authorizationContinuation else { return }
|
||||
self.authorizationContinuation = nil
|
||||
authorizationContinuation.resume()
|
||||
}
|
||||
|
||||
public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
|
||||
guard let location = locations.last else {
|
||||
locationContinuation.resume(
|
||||
throwing: LocationServiceError.locationUnavailable(reason: "No location was returned")
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
locationContinuation.resume(returning: location)
|
||||
}
|
||||
|
||||
public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
guard let locationContinuation else { return }
|
||||
self.locationContinuation = nil
|
||||
locationContinuation.resume(
|
||||
throwing: LocationServiceError.locationUnavailable(reason: error.localizedDescription)
|
||||
)
|
||||
}
|
||||
}
|
||||
+31
-1
@@ -6,12 +6,42 @@ import TempestasDomain
|
||||
@MainActor
|
||||
public final class MainViewModel {
|
||||
public private(set) var isLoading: Bool = false
|
||||
public private(set) var currentLocation: CurrentLocation?
|
||||
public private(set) var forecast: Forecast?
|
||||
public private(set) var loadError: Error?
|
||||
|
||||
private let forecastRepository: ForecastRepository
|
||||
private let locationService: LocationService
|
||||
|
||||
public init(
|
||||
forecastRepository: ForecastRepository
|
||||
forecastRepository: ForecastRepository,
|
||||
locationService: LocationService
|
||||
) {
|
||||
self.forecastRepository = forecastRepository
|
||||
self.locationService = locationService
|
||||
}
|
||||
|
||||
public func load(forceRefresh: Bool = false) async {
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
loadError = nil
|
||||
|
||||
do {
|
||||
await locationService.requestAuthorization()
|
||||
|
||||
let location = try await locationService.getCurrentLocation()
|
||||
currentLocation = location
|
||||
|
||||
forecast = try await forecastRepository.get(
|
||||
latitude: location.coordinate.latitude,
|
||||
longitude: location.coordinate.longitude,
|
||||
forceRefresh: forceRefresh
|
||||
)
|
||||
} catch {
|
||||
loadError = error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,10 @@ public struct MainView: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
NavigationStack {
|
||||
|
||||
}.frame(width: 320, height: 400)
|
||||
NavigationStack {}
|
||||
.frame(width: 320, height: 400)
|
||||
.task {
|
||||
await viewModel.load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -462,6 +462,7 @@
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Tempestas uses your location to show local weather conditions.";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
@@ -504,6 +505,7 @@
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Tempestas uses your location to show local weather conditions.";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
|
||||
@@ -5,18 +5,27 @@ import TempestasPresentation
|
||||
@MainActor
|
||||
final class AppComposition {
|
||||
private let forecastRepository: ForecastRepository
|
||||
private let locationService: LocationService
|
||||
|
||||
init(inMemory: Bool = false) {
|
||||
guard let forecastRepository = try? DefaultForecastRepository.makeDefault(inMemory: inMemory) else {
|
||||
fatalError("Failed to create the forecast cache store")
|
||||
}
|
||||
|
||||
let locationService = DefaultLocationService()
|
||||
|
||||
self.forecastRepository = forecastRepository
|
||||
self.locationService = locationService
|
||||
|
||||
Task { @MainActor in
|
||||
await locationService.requestAuthorization()
|
||||
}
|
||||
}
|
||||
|
||||
func makeMainViewModel() -> MainViewModel {
|
||||
MainViewModel(
|
||||
forecastRepository: forecastRepository
|
||||
forecastRepository: forecastRepository,
|
||||
locationService: locationService
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user