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
|
@MainActor
|
||||||
public final class MainViewModel {
|
public final class MainViewModel {
|
||||||
public private(set) var isLoading: Bool = false
|
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 forecastRepository: ForecastRepository
|
||||||
|
private let locationService: LocationService
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
forecastRepository: ForecastRepository
|
forecastRepository: ForecastRepository,
|
||||||
|
locationService: LocationService
|
||||||
) {
|
) {
|
||||||
self.forecastRepository = forecastRepository
|
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 {
|
public var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {}
|
||||||
|
.frame(width: 320, height: 400)
|
||||||
}.frame(width: 320, height: 400)
|
.task {
|
||||||
|
await viewModel.load()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -462,6 +462,7 @@
|
|||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Tempestas uses your location to show local weather conditions.";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
@@ -504,6 +505,7 @@
|
|||||||
ENABLE_USER_SELECTED_FILES = readonly;
|
ENABLE_USER_SELECTED_FILES = readonly;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||||
|
INFOPLIST_KEY_NSLocationWhenInUseUsageDescription = "Tempestas uses your location to show local weather conditions.";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
|
|||||||
@@ -5,18 +5,27 @@ import TempestasPresentation
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class AppComposition {
|
final class AppComposition {
|
||||||
private let forecastRepository: ForecastRepository
|
private let forecastRepository: ForecastRepository
|
||||||
|
private let locationService: LocationService
|
||||||
|
|
||||||
init(inMemory: Bool = false) {
|
init(inMemory: Bool = false) {
|
||||||
guard let forecastRepository = try? DefaultForecastRepository.makeDefault(inMemory: inMemory) else {
|
guard let forecastRepository = try? DefaultForecastRepository.makeDefault(inMemory: inMemory) else {
|
||||||
fatalError("Failed to create the forecast cache store")
|
fatalError("Failed to create the forecast cache store")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let locationService = DefaultLocationService()
|
||||||
|
|
||||||
self.forecastRepository = forecastRepository
|
self.forecastRepository = forecastRepository
|
||||||
|
self.locationService = locationService
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
await locationService.requestAuthorization()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeMainViewModel() -> MainViewModel {
|
func makeMainViewModel() -> MainViewModel {
|
||||||
MainViewModel(
|
MainViewModel(
|
||||||
forecastRepository: forecastRepository
|
forecastRepository: forecastRepository,
|
||||||
|
locationService: locationService
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user