chore: wire in location services

Signed-off-by: Alan Brault <alan.brault@visus.io>
This commit is contained in:
2026-08-09 10:22:38 -04:00
parent 59cb2737a1
commit e1da561ff6
9 changed files with 256 additions and 9 deletions
@@ -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
}
@@ -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)
)
}
}
@@ -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
}
}
}
@@ -3,15 +3,17 @@ import SwiftUI
public struct MainView: View {
let viewModel: MainViewModel
public init (
public init(
viewModel: MainViewModel
) {
self.viewModel = viewModel
}
public var body: some View {
NavigationStack {
}.frame(width: 320, height: 400)
NavigationStack {}
.frame(width: 320, height: 400)
.task {
await viewModel.load()
}
}
}
+2
View File
@@ -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",
+10 -1
View File
@@ -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
)
}
}