diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Entities/Coordinate.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Entities/Coordinate.swift new file mode 100644 index 0000000..1e5782a --- /dev/null +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Entities/Coordinate.swift @@ -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 + } +} diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Entities/CurrentLocation.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Entities/CurrentLocation.swift new file mode 100644 index 0000000..1d5ed58 --- /dev/null +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Entities/CurrentLocation.swift @@ -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 + } +} diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Enums/LocationServiceError.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Enums/LocationServiceError.swift new file mode 100644 index 0000000..7b20946 --- /dev/null +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Enums/LocationServiceError.swift @@ -0,0 +1,7 @@ +import Foundation + +public enum LocationServiceError: Error, Sendable, Hashable { + case authorizationDenied + case locationUnavailable(reason: String) + case geocodingFailed(reason: String) +} diff --git a/Packages/TempestasDomain/Sources/TempestasDomain/Services/LocationService.swift b/Packages/TempestasDomain/Sources/TempestasDomain/Services/LocationService.swift new file mode 100644 index 0000000..c3025ed --- /dev/null +++ b/Packages/TempestasDomain/Sources/TempestasDomain/Services/LocationService.swift @@ -0,0 +1,6 @@ +import Foundation + +public protocol LocationService: Sendable { + func requestAuthorization() async + func getCurrentLocation() async throws -> CurrentLocation +} diff --git a/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Services/DefaultLocationService.swift b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Services/DefaultLocationService.swift new file mode 100644 index 0000000..dd2f0c6 --- /dev/null +++ b/Packages/TempestasInfrastructure/Sources/TempestasInfrastructure/Services/DefaultLocationService.swift @@ -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? + private var locationContinuation: CheckedContinuation? + + private var authorizationTask: Task? + private var locationTask: Task? + + 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 = 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 = 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) + ) + } +} diff --git a/Packages/TempestasPresentation/Sources/TempestasPresentation/ViewModels/MainViewModel.swift b/Packages/TempestasPresentation/Sources/TempestasPresentation/ViewModels/MainViewModel.swift index 3c9f59c..0f3254b 100644 --- a/Packages/TempestasPresentation/Sources/TempestasPresentation/ViewModels/MainViewModel.swift +++ b/Packages/TempestasPresentation/Sources/TempestasPresentation/ViewModels/MainViewModel.swift @@ -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 + } } } diff --git a/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MainView.swift b/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MainView.swift index 9ac3366..dadc899 100644 --- a/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MainView.swift +++ b/Packages/TempestasPresentation/Sources/TempestasPresentation/Views/MainView.swift @@ -2,16 +2,18 @@ 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() + } } } diff --git a/Tempestas.xcodeproj/project.pbxproj b/Tempestas.xcodeproj/project.pbxproj index 81cb881..31d1563 100644 --- a/Tempestas.xcodeproj/project.pbxproj +++ b/Tempestas.xcodeproj/project.pbxproj @@ -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", diff --git a/Tempestas/AppComposition.swift b/Tempestas/AppComposition.swift index dbd6174..07ce060 100644 --- a/Tempestas/AppComposition.swift +++ b/Tempestas/AppComposition.swift @@ -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 ) } }