diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..892da1a --- /dev/null +++ b/.spi.yml @@ -0,0 +1,4 @@ +version: 1 +builder: + configs: + - documentation_targets: [swift-async-corelocation-streamer] diff --git a/LICENSE b/LICENSE index b2bea10..a13e43d 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2023 Igor +Copyright (c) 2023 Igor Shelopaev Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Package.swift b/Package.swift index b6c6ab2..6f3e757 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 5.6 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription @@ -6,7 +6,7 @@ import PackageDescription let package = Package( name: "d3-async-location", platforms: [ - .iOS(.v15) + .iOS(.v14), .watchOS(.v7), ], products: [ // Products define the executables and libraries a package produces, and make them visible to other packages. diff --git a/README.md b/README.md index 955aba6..4feefb3 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,84 @@ -# Async location streamer using new concurrency model in Swift +# Async location streamer using new concurrency + +### Please star the repository if you believe continuing the development of this package is worthwhile. This will help me understand which package deserves more effort. + +[![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fswift-async-corelocation-streamer%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/swiftuiux/swift-async-corelocation-streamer) [![](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fswiftuiux%2Fswift-async-corelocation-streamer%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/swiftuiux/swift-async-corelocation-streamer) + +This package uses Core Location under the hood to harness the power of GPS for accurate and efficient location tracking. By leveraging Swift’s async/await concurrency model, it provides a modern, clean, and scalable way to stream GPS data asynchronously. Core Location works with GPS, Wi-Fi, and cellular networks to determine a device’s position, ensuring both high accuracy and optimal performance. + +## [SwiftUI example](https://github.com/swiftuiux/corelocation-manager-tracker-swift-apple-maps-example) + + Available for watchOS + + ![simulate locations](https://github.com/swiftuiux/swift-async-corelocation-streamer/blob/main/img/apple_watch_swiftui.gif) + + + +if you are using the simulator don't forget to simulate locations + + ![simulate locations](https://github.com/swiftuiux/swift-async-corelocation-streamer/blob/main/img/image11.gif) ## Features -- [x] Using new concurrency swift model around CoreLocation -- [x] Streaming current locations asynchronously -- [x] Customizable in terms of accuracy -- [x] Errors handling +- [x] Using new concurrency swift model around CoreLocation manager +- [x] Extend API to allow customization of `CLLocationManager` +- [x] Support for iOS from 14.1 and watchOS from 7.0 +- [x] Seamless SwiftUI Integration Uses `@Published` properties for real-time UI updates or @observable is you can afford iOS17 or newer. +- [x] Streaming current location asynchronously +- [x] Different strategies - Keep and publish all stack of locations since streaming has started or the last one +- [x] Errors handling (as **AsyncLocationErrors** so CoreLocation errors **CLError**) + ## How to use ### 1. Add to info the option "Privacy - Location When In Use Usage Description" - ![Stories life circle](https://github.com/The-Igor/d3-location/blob/main/img/image2.png) + ![Add to info](https://github.com/swiftuiux/swift-async-corelocation-streamer/blob/main/img/image2.png) -### 2. Add or inject LMViewModel into a View + **Background Updates** + - Ensure the app has `location` included in `UIBackgroundModes` for background updates to function. + +### 2. Add or inject LocationStreamer into a View ``` - @EnvironmentObject var model: LMViewModel + @StateObject var service: LocationStreamer ``` - -### 3. call ViewModel method start() within async environment of the View - +For iOS 17+ and watchOS 10+, using @State macro: ``` - Task{ - do{ - try await model.start() - }catch{ - self.error = error.localizedDescription - } - } + @State var service: ObservableLocationStreamer ``` -### 4. Process async stream of locations from "locations" property of the ViewModel +### 3. Call LocationStreamer method start() within async environment or check SwiftUI example ``` - @ViewBuilder - var coordinatesTpl: some View{ - List(viewModel.locations, id: \.hash) { location in - Text("\(location.coordinate.longitude), \(location.coordinate.latitude)") - } - } + try await service.start() ``` -### 5. Showcase possible errors from LMViewModel in UI is up to you -``` - ///Status is not determined If you are trying to get Async stream without - permission request in case you implement your own ViewModel and access LocationManagerAsync.locations - case statusIsNotDetermined - - ///Access was denied by user - case accessIsNotAuthorized -``` +### LocationStreamer parameters -## ViewModel API -``` -public protocol ILocationManagerViewModel: ObservableObject{ - - /// List of locations - @MainActor - var locations : [CLLocation] { get } - - /// Start streaming locations - func start() async throws - - /// Stop streaming locations - func stop() async -} -``` +|Param|Description| +| --- | --- | +|strategy| Strategy for publishing locations. Default value is **KeepLastStrategy**. Another predefined option is **KeepAllStrategy**, or you can implement and test your own custom strategy by conforming to the `LocationResultStrategy` protocol. | +|accuracy| The accuracy of a geographical coordinate.| +|activityType| Constants indicating the type of activity associated with location updates.| +|distanceFilter| A distance in meters from an existing location to trigger updates.| +|backgroundUpdates| A Boolean value that indicates whether the app receives location updates when running in the background. | -## SwiftUI example of using package -[d3-stories-instagram-example](https://github.com/The-Igor/async-location-swift-example) +or + +|Param|Description| +| --- | --- | +|strategy| Strategy for publishing locations. Default value is **KeepLastStrategy**. Another predefined option is **KeepAllStrategy**, or you can implement and test your own custom strategy by conforming to the `LocationResultStrategy` protocol. | +|locationManager| A pre-configured `CLLocationManager`. | -if you are using the simulator don't forget to simulate locations - ![Stories life circle](https://github.com/The-Igor/d3-location/blob/main/img/image3.gif) +### Default location +1. Product > Scheme > Edit Scheme +2. Click Run .app +3. Option tab +4. Already checked Core Location > select your location +5. Press OK + ![Default location](https://github.com/swiftuiux/swift-async-corelocation-streamer/blob/main/img/image6.png) + + ## Documentation(API) - You need to have Xcode 13 installed in order to have access to Documentation Compiler (DocC) - Go to Product > Build Documentation or **⌃⇧⌘ D** diff --git a/Sources/d3-async-location/LocationManagerAsync+/Delegate.swift b/Sources/d3-async-location/LocationManagerAsync+/Delegate.swift new file mode 100644 index 0000000..6e03ee9 --- /dev/null +++ b/Sources/d3-async-location/LocationManagerAsync+/Delegate.swift @@ -0,0 +1,119 @@ +// +// Delegate.swift +// +// +// Created by Igor on 07.02.2023. +// + +import CoreLocation + +extension LocationManager { + + /// Delegate class that implements `CLLocationManagerDelegate` methods to receive location updates + /// and errors from `CLLocationManager`, and forwards them into an `AsyncFIFOQueue` for asynchronous consumption. + @available(iOS 14.0, watchOS 7.0, *) + final class Delegate: NSObject, ILocationDelegate { + + typealias DelegateOutput = LocationStreamer.Output + + /// The `CLLocationManager` instance used to obtain location updates. + private let manager: CLLocationManager + + /// The FIFO queue used to emit location updates or errors as an asynchronous stream. + private let fifoQueue: AsyncFIFOQueue + + // MARK: - Lifecycle + + /// Initializes the delegate with specified location settings. + /// - Parameters: + /// - accuracy: The desired accuracy of the location data. + /// - activityType: The type of user activity associated with the location updates. + /// - distanceFilter: The minimum distance (in meters) the device must move before an update event is generated. + /// - backgroundUpdates: A Boolean indicating whether the app should receive location updates when suspended. + public init( + _ accuracy: CLLocationAccuracy?, + _ activityType: CLActivityType?, + _ distanceFilter: CLLocationDistance?, + _ backgroundUpdates: Bool = false + ) { + self.manager = CLLocationManager() + self.fifoQueue = AsyncFIFOQueue() + super.init() + manager.delegate = self + updateSettings(accuracy, activityType, distanceFilter, backgroundUpdates) + } + + /// Initializes the delegate with a given `CLLocationManager` instance. + /// - Parameter locationManager: The `CLLocationManager` instance to manage location updates. + public init(locationManager: CLLocationManager) { + self.manager = locationManager + self.fifoQueue = AsyncFIFOQueue() + super.init() + manager.delegate = self + } + + deinit { + finish() + manager.delegate = nil + #if DEBUG + print("deinit delegate") + #endif + } + + // MARK: - API + + /// Starts location streaming. + /// - Returns: An async stream of location outputs. + public func start() -> AsyncStream { + // Initialize the stream when needed. + let stream = fifoQueue.initializeQueue() + + manager.startUpdatingLocation() + return stream + } + + /// Stops location updates and finishes the asynchronous FIFO queue. + public func finish() { + fifoQueue.finish() + manager.stopUpdatingLocation() + } + + /// Requests location permissions if not already granted. + /// - Throws: An error if the permission is not granted. + public func permission() async throws { + let permission = Permission(with: manager.authorizationStatus) + try await permission.grant(for: manager) + } + + // MARK: - Delegate Methods + + public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + fifoQueue.enqueue(.success(locations)) + } + + public func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + let cleError = error as? CLError ?? CLError(.locationUnknown) + fifoQueue.enqueue(.failure(cleError)) + } + + public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + NotificationCenter.default.post(name: Permission.authorizationStatus, object: manager.authorizationStatus) + } + + // MARK: - Private Methods + + private func updateSettings( + _ accuracy: CLLocationAccuracy?, + _ activityType: CLActivityType?, + _ distanceFilter: CLLocationDistance?, + _ backgroundUpdates: Bool = false + ) { + manager.desiredAccuracy = accuracy ?? kCLLocationAccuracyBest + manager.activityType = activityType ?? .other + manager.distanceFilter = distanceFilter ?? kCLDistanceFilterNone + #if os(iOS) || os(watchOS) + manager.allowsBackgroundLocationUpdates = backgroundUpdates + #endif + } + } +} diff --git a/Sources/d3-async-location/LocationManagerAsync+/Permission.swift b/Sources/d3-async-location/LocationManagerAsync+/Permission.swift new file mode 100644 index 0000000..9bdb89f --- /dev/null +++ b/Sources/d3-async-location/LocationManagerAsync+/Permission.swift @@ -0,0 +1,119 @@ +// +// Permission.swift +// +// +// Created by Igor on 06.02.2023. +// + +import CoreLocation +import Combine +import SwiftUI + +extension LocationManager{ + + /// Helper class to determine permission to get access for streaming ``CLLocation`` + @available(iOS 14.0, watchOS 7.0, *) + final class Permission{ + + /// Name of notification for event location manager changed authorization status + static public let authorizationStatus = Notification.Name("authorizationStatus") + + // MARK: - Private properties + + /// The current authorization status for the app + private var status : CLAuthorizationStatus + + /// Continuation to get permission if status is not defined + private var flow : CheckedContinuation? + + /// Check if status is determined + private var isDetermined : Bool{ + status != .notDetermined + } + + /// Subscription to authorization status changes + private var cancelable : AnyCancellable? + + // MARK: - Life circle + + /// Init defining is access to location service is granted + /// - Parameter status: Constant indicating the app's authorization to use location services + init(with status: CLAuthorizationStatus){ + self.status = status + initSubscription() + } + + /// resume continuation if it was not called with status .notDetermined + deinit { + resume(with: .notDetermined) + } + + // MARK: - API + + /// Get status asynchronously and check is it authorized to start getting the stream of locations + public func grant(for manager: CLLocationManager) async throws { + let status = await requestPermission(manager) + if status.isNotAuthorized{ + throw AsyncLocationErrors.accessIsNotAuthorized + } + } + + // MARK: - Private methods + + /// Subscribe for event when location manager change authorization status to go on access permission flow + private func initSubscription(){ + let name = Permission.authorizationStatus + cancelable = NotificationCenter.default.publisher(for: name) + .sink { [weak self] value in + self?.statusChanged(value) + } + } + + /// Determine status after the request permission + /// - Parameter manager: Location manager + private func statusChanged(_ value: Output) { + if let s = value.object as? CLAuthorizationStatus{ + status = s + resume(with: status) + } + } + + /// Resume continuation + /// - Parameter status: resume + private func resume(with status : CLAuthorizationStatus) { + flow?.resume(returning: status) + flow = nil + } + + /// Requests the user’s permission to use location services while the app is in use + /// Don't forget to add in Info "Privacy - Location When In Use Usage Description" something like "Show list of locations" + /// - Returns: Permission status + private func requestPermission(_ manager : CLLocationManager) async -> CLAuthorizationStatus{ + manager.requestWhenInUseAuthorization() + + if isDetermined{ + return status + } + + return await withCheckedContinuation{ continuation in + flow = continuation + } + } + } +} + +// MARK: - Alias types - + +fileprivate typealias Output = NotificationCenter.Publisher.Output + +// MARK: - Extensions - + +fileprivate extension CLAuthorizationStatus { + /// Check if access is not authorized + /// denied - The user denied the use of location services for the app or they are disabled globally in Settings + /// restricted - The app is not authorized to use location services + /// - Returns: Return `True` if it was denied + var isNotAuthorized: Bool { + [.denied, .restricted].contains(self) + } +} diff --git a/Sources/d3-async-location/LocationManagerAsync.swift b/Sources/d3-async-location/LocationManagerAsync.swift deleted file mode 100644 index 5067d52..0000000 --- a/Sources/d3-async-location/LocationManagerAsync.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// LocationManagerAsync.swift -// -// -// Created by Igor on 03.02.2023. -// - -import CoreLocation - - -/// Manager of locations streaming data asynchronously -@available(iOS 15.0, *) -public final class LocationManagerAsync: NSObject, CLLocationManagerDelegate{ - - // MARK: - Public properties - - public var locations : AsyncStream{ - get throws { - try checkStatus() - return AsyncStream(CLLocation.self) { continuation in - start(with: continuation) - } - } - } - - // MARK: - Private properties - - private typealias StreamType = AsyncStream.Continuation - - private var stream: StreamType?{ - didSet { - stream?.onTermination = { @Sendable _ in self.stop() } - } - } - - /// Continuation to get permission is status is not defined - private var permission : CheckedContinuation? - - /// Location manager - private let manager : CLLocationManager - - /// Current status - private var status : CLAuthorizationStatus - - /// Check if status is determined - private var isDetermined : Bool{ - status != .notDetermined - } - - // MARK: - Life circle - - public convenience init(accuracy : CLLocationAccuracy?){ - - self.init() - - managerSettings(accuracy: accuracy) - } - - override init(){ - - manager = CLLocationManager() - - status = manager.authorizationStatus - - super.init() - - } - - // MARK: - API - - /// Request permission - /// Don't forget to add in Info "Privacy - Location When In Use Usage Description" something like "Show list of locations" - /// - Returns: Permission status - public func requestPermission() async -> CLAuthorizationStatus{ - manager.requestWhenInUseAuthorization() - - if isDetermined{ return status } - - //Suspension point until we get the response from the user according the permission - return await withCheckedContinuation{ continuation in - permission = continuation - } - - } - - /// Stop updating - public func stop(){ - stream = nil - manager.stopUpdatingLocation() - } - - // MARK: - Private - - /// Set manager's properties - /// - Parameter accuracy: Desired accuracy - private func managerSettings(accuracy : CLLocationAccuracy?){ - manager.delegate = self - manager.desiredAccuracy = accuracy ?? kCLLocationAccuracyBest - } - - /// Start updating - private func start(with continuation : StreamType){ - stream = continuation - manager.startUpdatingLocation() - } - - private func yield(location : CLLocation){ - stream?.yield(location) - } - - private func checkStatus() throws{ - if !isDetermined{ - throw LocationManagerErrors.statusIsNotDetermined - } - } - - // MARK: - Delegate - - /// Pass locations into the async stream - /// - Parameters: - /// - manager: Location manager - /// - locations: Array of locations - public func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { - locations.forEach{ yield(location: $0) } - } - - /// Determine status after the request permission - /// - Parameter manager: Location manager - public func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { - status = manager.authorizationStatus - permission?.resume(returning: status) - } - -} diff --git a/Sources/d3-async-location/enum/AsyncLocationErrors.swift b/Sources/d3-async-location/enum/AsyncLocationErrors.swift new file mode 100644 index 0000000..cf504d6 --- /dev/null +++ b/Sources/d3-async-location/enum/AsyncLocationErrors.swift @@ -0,0 +1,27 @@ +// +// LocationManagerErrors.swift +// +// +// Created by Igor on 03.02.2023. +// + +import CoreLocation + +/// Async locations manager errors +@available(iOS 14.0, watchOS 7.0, *) +public enum AsyncLocationErrors: Error{ + ///Access was denied by user + case accessIsNotAuthorized +} + + +@available(iOS 14.0, watchOS 7.0, *) +extension AsyncLocationErrors: LocalizedError { + + public var errorDescription: String? { + switch self { + case .accessIsNotAuthorized: + return NSLocalizedString("Access was denied by the user.", comment: "") + } + } +} diff --git a/Sources/d3-async-location/enum/LocationManagerErrors.swift b/Sources/d3-async-location/enum/LocationManagerErrors.swift deleted file mode 100644 index 8b407b3..0000000 --- a/Sources/d3-async-location/enum/LocationManagerErrors.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// LocationManagerErrors.swift -// -// -// Created by Igor on 03.02.2023. -// - -import Foundation - -/// Async locations manager errors -@available(iOS 15.0, *) -public enum LocationManagerErrors: Error{ - - ///Status is not determined If you trying to get Async stream without permission request - case statusIsNotDetermined - - ///Access was denied by user - case accessIsNotAuthorized - -} diff --git a/Sources/d3-async-location/enum/LocationStreamingState.swift b/Sources/d3-async-location/enum/LocationStreamingState.swift new file mode 100644 index 0000000..e45d72b --- /dev/null +++ b/Sources/d3-async-location/enum/LocationStreamingState.swift @@ -0,0 +1,19 @@ +// +// LocationStreamingState.swift +// +// +// Created by Igor on 04.02.2023. +// + +import Foundation + +/// Streaming states +@available(iOS 14.0, watchOS 7.0, *) +public enum LocationStreamingState{ + + /// not streaming + case idle + + /// streaming has been started + case streaming +} diff --git a/Sources/d3-async-location/helper/AsyncFIFOQueue.swift b/Sources/d3-async-location/helper/AsyncFIFOQueue.swift new file mode 100644 index 0000000..7e0c2b7 --- /dev/null +++ b/Sources/d3-async-location/helper/AsyncFIFOQueue.swift @@ -0,0 +1,75 @@ +// +// AsyncFIFOQueue.swift +// d3-async-location +// +// Created by Igor Shelopaev on 04.12.24. +// + +import Foundation + +extension LocationManager { + + /// A generic FIFO queue that provides an asynchronous stream of elements. + /// The stream is initialized lazily and can be terminated and cleaned up. + @available(iOS 14.0, watchOS 7.0, *) + final class AsyncFIFOQueue{ + + /// Type alias for the AsyncStream Continuation. + typealias Continuation = AsyncStream.Continuation + + + /// The asynchronous stream that consumers can iterate over. + private var stream: AsyncStream? + + /// The continuation used to produce values for the stream. + private var continuation: Continuation? + + /// Initializes the stream and continuation. + /// Should be called before starting to enqueue elements. + /// - Parameter onTermination: An escaping closure to handle termination events. + /// - Returns: The initialized `AsyncStream`. + func initializeQueue() -> AsyncStream { + // Return the existing stream if it's already initialized. + if let existingStream = stream { + return existingStream + } + + let (newStream, newContinuation) = AsyncStream.makeStream(of: Element.self) + + newContinuation.onTermination = { [weak self] termination in + self?.finish() + } + + self.stream = newStream + self.continuation = newContinuation + + return newStream + } + + /// Provides access to the asynchronous stream. + /// - Returns: The initialized `AsyncStream` instance, or `nil` if not initialized. + func getQueue() -> AsyncStream? { + return stream + } + + /// Enqueues a new element into the stream. + /// - Parameter element: The element to enqueue. + func enqueue(_ element: Element) { + continuation?.yield(element) + } + + /// Finishes the stream, indicating no more elements will be enqueued. + /// Cleans up the stream and continuation. + func finish() { + continuation?.finish() + continuation = nil + stream = nil + } + + deinit { + #if DEBUG + print("deinit async queue") + #endif + } + } +} diff --git a/Sources/d3-async-location/helper/LocationManager.swift b/Sources/d3-async-location/helper/LocationManager.swift new file mode 100644 index 0000000..423f39e --- /dev/null +++ b/Sources/d3-async-location/helper/LocationManager.swift @@ -0,0 +1,68 @@ +// +// LocationManagerAsync.swift +// +// Created by Igor on 03.02.2023. +// + +import CoreLocation + +/// A location manager that streams data asynchronously using an `AsyncStream`. +/// It requests permission in advance if it hasn't been determined yet. +/// Use the `start()` method to begin streaming location updates. +@available(iOS 14.0, watchOS 7.0, *) +final class LocationManager: ILocationManager { + + /// The delegate responsible for handling location updates and forwarding them to the async stream. + private let delegate: ILocationDelegate + + // MARK: - Lifecycle + + /// Initializes a new `LocationManagerAsync` instance with the specified settings. + /// - Parameters: + /// - accuracy: The desired accuracy of the location data. + /// - activityType: The type of user activity associated with the location updates. + /// - distanceFilter: The minimum distance (in meters) that the device must move before an update event is generated. kCLDistanceFilterNone (equivalent to -1.0) means updates are sent regardless of the distance traveled. This is a safe default for apps that don’t require filtering updates based on distance. + /// - backgroundUpdates: A Boolean value indicating whether the app should receive location updates when suspended. + init( + _ accuracy: CLLocationAccuracy?, + _ activityType: CLActivityType?, + _ distanceFilter: CLLocationDistance?, + _ backgroundUpdates: Bool + ) { + delegate = LocationManager.Delegate(accuracy, activityType, distanceFilter, backgroundUpdates) + } + + /// Initializes the `LocationManagerAsync` instance with a specified `CLLocationManager` instance. + /// - Parameter locationManager: A pre-configured `CLLocationManager` instance used to manage location updates. + public init(locationManager: CLLocationManager) { + delegate = LocationManager.Delegate(locationManager: locationManager) + } + + /// Deinitializes the `LocationManagerAsync` instance, performing any necessary cleanup. + deinit { + #if DEBUG + print("deinit manager") + #endif + } + + // MARK: - API + + /// Checks permission status and starts streaming location data asynchronously. + /// - Returns: An `AsyncStream` emitting location updates or errors. + /// - Throws: An error if permission is not granted. + public func start() async throws -> AsyncStream { + + try await delegate.permission() + + return delegate.start() + } + + /// Stops the location streaming process. + public func stop() { + delegate.finish() + + #if DEBUG + print("stop updating") + #endif + } +} diff --git a/Sources/d3-async-location/helper/Strategies.swift b/Sources/d3-async-location/helper/Strategies.swift new file mode 100644 index 0000000..808eb73 --- /dev/null +++ b/Sources/d3-async-location/helper/Strategies.swift @@ -0,0 +1,48 @@ +// +// Strategies.swift +// +// +// Created by Igor Shelopaev on 10.02.2023. +// + +import Foundation + + + +/// A concrete strategy that keeps only the last location result. +/// This strategy ensures that only the most recent location update is retained. +@available(iOS 14.0, watchOS 7.0, *) +public struct KeepLastStrategy: ILocationResultStrategy { + + /// Initializes a new instance of `KeepLastStrategy`. + public init() {} + + /// Processes the results array by replacing it with the new result. + /// - Parameters: + /// - results: The current array of processed results (ignored in this strategy). + /// - newResult: The new result to be stored. + /// - Returns: An array containing only the new result. + public func process(results: [Output], newResult: Output) -> [Output] { + return [newResult] + } +} + +/// A concrete strategy that keeps all location results. +/// This strategy appends each new location result to the existing array. +@available(iOS 14.0, watchOS 7.0, *) +public struct KeepAllStrategy: ILocationResultStrategy { + + /// Initializes a new instance of `KeepAllStrategy`. + public init() {} + + /// Processes the results array by appending the new result to it. + /// - Parameters: + /// - results: The current array of processed results. + /// - newResult: The new result to be added to the array. + /// - Returns: A new array of results, including the new result appended to the existing results. + public func process(results: [Output], newResult: Output) -> [Output] { + var updatedResults = results + updatedResults.append(newResult) + return updatedResults + } +} diff --git a/Sources/d3-async-location/protocol/ILocationDelegate.swift b/Sources/d3-async-location/protocol/ILocationDelegate.swift new file mode 100644 index 0000000..0fe3270 --- /dev/null +++ b/Sources/d3-async-location/protocol/ILocationDelegate.swift @@ -0,0 +1,33 @@ +// +// ILocationDelegate.swift +// +// +// Created by Igor on 05.07.2023. +// + +import Foundation +import CoreLocation + +/// A protocol that defines the interface for location delegates. +@available(iOS 14.0, watchOS 7.0, *) +public protocol ILocationDelegate: NSObjectProtocol, CLLocationManagerDelegate { + + /// Starts the location streaming process. + /// + /// - Returns: An `AsyncStream` emitting `LocationStreamer.Output` values. + /// The stream provides asynchronous location updates or errors to the consumer. + func start() -> AsyncStream + + /// Stops the location streaming process. + /// + /// Cleans up resources, stops receiving location updates, + /// and ensures that any ongoing streams are properly terminated. + func finish() + + /// Requests the necessary location permissions from the user if not already granted. + /// + /// - Throws: An error if the permission request fails or the user denies access. + /// + /// This method should be called before starting the location updates to ensure that the app has the required permissions. + func permission() async throws +} diff --git a/Sources/d3-async-location/protocol/ILocationManager.swift b/Sources/d3-async-location/protocol/ILocationManager.swift new file mode 100644 index 0000000..1efaa94 --- /dev/null +++ b/Sources/d3-async-location/protocol/ILocationManager.swift @@ -0,0 +1,21 @@ +// +// File.swift +// +// +// Created by Igor on 03.02.2023. +// + +import Foundation +import CoreLocation + +@available(iOS 14.0, watchOS 7.0, *) +protocol ILocationManager { + + /// Starts the async stream of location updates. + /// - Returns: An `AsyncStream` of `Output` that emits location updates or errors. + /// - Throws: An error if the streaming cannot be started. + func start() async throws -> AsyncStream + + /// Stops the location streaming process. + func stop() +} diff --git a/Sources/d3-async-location/protocol/ILocationManagerViewModel.swift b/Sources/d3-async-location/protocol/ILocationManagerViewModel.swift deleted file mode 100644 index faca431..0000000 --- a/Sources/d3-async-location/protocol/ILocationManagerViewModel.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// ILocationManagerViewModel.swift -// -// -// Created by Igor on 03.02.2023. -// - -import CoreLocation -import SwiftUI - -@available(iOS 15.0, *) -public protocol ILocationManagerViewModel: ObservableObject{ - - /// List of locations - @MainActor - var locations : [CLLocation] { get } - - /// Start streaming locations - func start() async throws - - /// Stop streaming locations - func stop() async -} diff --git a/Sources/d3-async-location/protocol/ILocationResultStrategy.swift b/Sources/d3-async-location/protocol/ILocationResultStrategy.swift new file mode 100644 index 0000000..e13ab6a --- /dev/null +++ b/Sources/d3-async-location/protocol/ILocationResultStrategy.swift @@ -0,0 +1,25 @@ +// +// ILocationResultStrategy.swift +// d3-async-location +// +// Created by Igor on 11.12.24. +// + +/// A protocol that defines a strategy for processing location results. +/// Implementations of this protocol determine how new location results are handled +/// (e.g., whether they replace the previous results or are appended). +@available(iOS 14.0, watchOS 7.0, *) +public protocol ILocationResultStrategy { + + /// The type of output that the strategy processes. + /// `Output` is defined as `LocationStreamer.Output`, which is a `Result` containing + /// an array of `CLLocation` objects or a `CLError`. + typealias Output = LocationStreamer.Output + + /// Processes the results array by incorporating a new location result. + /// - Parameters: + /// - results: The current array of processed results. + /// - newResult: The new result to be processed and added. + /// - Returns: A new array of results after applying the strategy. + func process(results: [Output], newResult: Output) -> [Output] +} diff --git a/Sources/d3-async-location/protocol/ILocationStreamer.swift b/Sources/d3-async-location/protocol/ILocationStreamer.swift new file mode 100644 index 0000000..ad7b662 --- /dev/null +++ b/Sources/d3-async-location/protocol/ILocationStreamer.swift @@ -0,0 +1,26 @@ +// +// ILocationManagerViewModel.swift +// +// +// Created by Igor on 03.02.2023. +// + +import CoreLocation +import SwiftUI + +@available(iOS 14.0, watchOS 7.0, *) +@MainActor +public protocol ILocationStreamer{ + + /// List of locations + var results : [LocationStreamer.Output] { get } + + /// Strategy for publishing locations Default value is .keepLast + var strategy : ILocationResultStrategy { get } + + /// Start streaming locations + func start(clean: Bool) async throws + + /// Stop streaming locations + func stop() +} diff --git a/Sources/d3-async-location/service/LocationStreamer.swift b/Sources/d3-async-location/service/LocationStreamer.swift new file mode 100644 index 0000000..f6e8293 --- /dev/null +++ b/Sources/d3-async-location/service/LocationStreamer.swift @@ -0,0 +1,152 @@ +// +// LocationStreamer.swift +// +// +// Created by Igor Shelopaev on 03.02.2023. +// + +import SwiftUI +import CoreLocation + +/// A ViewModel for asynchronously streaming location updates. +/// This class leverages `ObservableObject` to publish updates for SwiftUI Views. +/// +/// Example usage in a View: +/// ``` +/// @EnvironmentObject var model: LocationStreamer +/// ``` +/// +/// To start streaming updates, call the `start()` method within an async environment. +/// +/// - Available: iOS 14.0+, watchOS 7.0+ +@available(iOS 14.0, watchOS 7.0, *) +public final class LocationStreamer: ILocationStreamer, ObservableObject { + + /// Represents the output of the location manager. + /// Each output is either: + /// - A list of results (`[CLLocation]` objects), or + /// - A `CLError` in case of failure. + public typealias Output = Result<[CLLocation], CLError> + + // MARK: - Public Properties + + /// Defines the strategy for processing and publishing location updates. + /// Default strategy retains only the most recent update (`KeepLastStrategy`). + public let strategy: ILocationResultStrategy + + /// A list of location results, published for subscribing Views. + /// This property is updated based on the chosen `strategy`. + @MainActor @Published public private(set) var results: [Output] = [] + + /// Indicates the current streaming state of the ViewModel. + /// State transitions include `.idle`, `.streaming`, and `.error`. + @MainActor @Published public private(set) var state: LocationStreamingState = .idle + + // MARK: - Private Properties + + /// Handles the actual location updates asynchronously. + private let manager: LocationManager + + /// Checks if the streaming process is idle. + /// A computed property for convenience. + @MainActor + public var isIdle: Bool { + return state == .idle + } + + // MARK: - Lifecycle + + /// Initializes the `LocationStreamer` with configurable parameters. + /// - Parameters: + /// - strategy: A `LocationResultStrategy` for managing location results. Defaults to `KeepLastStrategy`. + /// - accuracy: Specifies the desired accuracy of location updates. Defaults to `kCLLocationAccuracyBest`. + /// - activityType: The type of activity for location updates (e.g., automotive, fitness). Defaults to `.other`. + /// - distanceFilter: The minimum distance (in meters) before generating an update. Defaults to `kCLDistanceFilterNone` (no filtering). + /// - backgroundUpdates: Whether the app should continue receiving location updates in the background. Defaults to `false`. + public init( + strategy: ILocationResultStrategy = KeepLastStrategy(), + _ accuracy: CLLocationAccuracy? = kCLLocationAccuracyBest, + _ activityType: CLActivityType? = .other, + _ distanceFilter: CLLocationDistance? = kCLDistanceFilterNone, + _ backgroundUpdates: Bool = false + ) { + self.strategy = strategy + manager = .init(accuracy, activityType, distanceFilter, backgroundUpdates) + } + + /// Initializes the `LocationStreamer` with a pre-configured `CLLocationManager`. + /// - Parameters: + /// - strategy: A `LocationResultStrategy` for managing location results. Defaults to `KeepLastStrategy`. + /// - locationManager: A pre-configured `CLLocationManager` instance. + public init( + strategy: ILocationResultStrategy = KeepLastStrategy(), + locationManager: CLLocationManager + ) { + self.strategy = strategy + manager = .init(locationManager: locationManager) + } + + /// Cleans up resources when the instance is deallocated. + deinit { + #if DEBUG + print("deinit LocationStreamer") + #endif + } + + // MARK: - API + + /// Starts streaming location updates asynchronously. + /// - Parameters: + /// - clean: Whether to clear previous results before starting. Defaults to `true`. + /// - Throws: `AsyncLocationErrors.streamingProcessHasAlreadyStarted` if streaming is already active. + @MainActor + public func start(clean: Bool = true) async throws { + if state == .streaming { stop() } + if clean { self.clean() } + + setState(.streaming) + + let stream = try await manager.start() + + for await result in stream { + add(result) + } + + stop() + setState(.idle) + } + + /// Stops the location streaming process and sets the state to idle. + @MainActor + public func stop() { + manager.stop() + setState(.idle) + + #if DEBUG + print("stop manager") + #endif + } + + // MARK: - Private Methods + + /// Clears all stored results. + @MainActor + private func clean() { + results = [] + } + + /// Adds a new location result to the `results` array. + /// The behavior depends on the configured `strategy`. + /// - Parameter result: The new result to be processed and added. + @MainActor + private func add(_ result: Output) { + results = strategy.process(results: results, newResult: result) + } + + /// Updates the streaming state of the ViewModel. + /// - Parameter value: The new state to set (e.g., `.idle`, `.streaming`). + @MainActor + private func setState(_ value: LocationStreamingState) { + state = value + } +} diff --git a/Sources/d3-async-location/service/ObservableLocationStreamer.swift b/Sources/d3-async-location/service/ObservableLocationStreamer.swift new file mode 100644 index 0000000..5fb9d00 --- /dev/null +++ b/Sources/d3-async-location/service/ObservableLocationStreamer.swift @@ -0,0 +1,140 @@ +// +// ObservableLocationStreamer.swift +// +// +// Created by Igor on 03.02.2023. +// + +import SwiftUI +import CoreLocation + +#if compiler(>=5.9) && canImport(Observation) + +/// ViewModel for asynchronously posting location updates. +@available(iOS 17.0, watchOS 10.0, *) +@Observable +public final class ObservableLocationStreamer: ILocationStreamer{ + + /// Represents the output of the location manager. + /// Contains either a list of results (e.g., `CLLocation` objects) or a `CLError` in case of failure. + public typealias Output = Result<[CLLocation], CLError> + + // MARK: - Public Properties + + /// Strategy for publishing updates. Default value is `.keepLast`. + public let strategy: ILocationResultStrategy + + /// A list of results published for subscribed Views. + /// Results may include various types of data (e.g., `CLLocation` objects) depending on the implementation. + /// Use this publisher to feed Views with updates or create a proxy to manipulate the flow, + /// such as filtering, mapping, or dropping results. + @MainActor public private(set) var results: [Output] = [] + + /// Current streaming state of the ViewModel. + @MainActor public private(set) var state: LocationStreamingState = .idle + + // MARK: - Private Properties + + /// The asynchronous locations manager responsible for streaming updates. + private let manager: LocationManager + + /// Indicates whether the streaming process is idle. + @MainActor + public var isIdle: Bool { + return state == .idle + } + + // MARK: - Lifecycle + + /// Initializes the `LocationStreamer` with configurable parameters. + /// - Parameters: + /// - strategy: A `LocationResultStrategy` for managing location results. Defaults to `KeepLastStrategy`. + /// - accuracy: Specifies the desired accuracy of location updates. Defaults to `kCLLocationAccuracyBest`. + /// - activityType: The type of activity for location updates (e.g., automotive, fitness). Defaults to `.other`. + /// - distanceFilter: The minimum distance (in meters) before generating an update. Defaults to `kCLDistanceFilterNone` (no filtering). + /// - backgroundUpdates: Whether the app should continue receiving location updates in the background. Defaults to `false`. + public init( + strategy: ILocationResultStrategy = KeepLastStrategy(), + _ accuracy: CLLocationAccuracy? = kCLLocationAccuracyBest, + _ activityType: CLActivityType? = .other, + _ distanceFilter: CLLocationDistance? = kCLDistanceFilterNone, + _ backgroundUpdates: Bool = false + ) { + self.strategy = strategy + manager = .init(accuracy, activityType, distanceFilter, backgroundUpdates) + } + + /// Initializes the `LocationStreamer` with a pre-configured `CLLocationManager`. + /// - Parameters: + /// - strategy: A `LocationResultStrategy` for managing location results. Defaults to `KeepLastStrategy`. + /// - locationManager: A pre-configured `CLLocationManager` instance. + public init( + strategy: ILocationResultStrategy = KeepLastStrategy(), + locationManager: CLLocationManager + ) { + self.strategy = strategy + manager = .init(locationManager: locationManager) + } + + /// Cleans up resources when the instance is deallocated. + deinit { + #if DEBUG + print("deinit LocationStreamer") + #endif + } + + // MARK: - API + + /// Starts streaming location updates asynchronously. + /// - Parameters: + /// - clean: Whether to clear previous results before starting. Defaults to `true`. + /// - Throws: `AsyncLocationErrors.streamingProcessHasAlreadyStarted` if streaming is already active. + @MainActor public func start(clean: Bool = true) async throws { + if state == .streaming { stop() } + if clean { self.clean() } + + setState(.streaming) + + let stream = try await manager.start() + + for await result in stream { + add(result) + } + setState(.idle) + } + + /// Stops the location streaming process and sets the state to idle. + @MainActor public func stop() { + manager.stop() + setState(.idle) + + #if DEBUG + print("stop manager") + #endif + } + + // MARK: - Private Methods + + /// Clears all stored results. + @MainActor + private func clean() { + results = [] + } + + /// Adds a new location result to the `results` array. + /// The behavior depends on the configured `strategy`. + /// - Parameter result: The new result to be processed and added. + @MainActor + private func add(_ result: Output) { + results = strategy.process(results: results, newResult: result) + } + + /// Updates the streaming state of the ViewModel. + /// - Parameter value: The new state to set (e.g., `.idle`, `.streaming`). + @MainActor + private func setState(_ value: LocationStreamingState) { + state = value + } +} + +#endif diff --git a/Sources/d3-async-location/viewmodel/LMViewModel.swift b/Sources/d3-async-location/viewmodel/LMViewModel.swift deleted file mode 100644 index d1da156..0000000 --- a/Sources/d3-async-location/viewmodel/LMViewModel.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// LocationManagerViewModel.swift -// -// -// Created by Igor on 03.02.2023. -// - -import SwiftUI -import CoreLocation - - -/// Viewmodel posting locations -@available(iOS 15.0, *) -public final class LMViewModel: ILocationManagerViewModel{ - - // MARK: - Public - - /// List of locations - @MainActor @Published public private(set) var locations : [CLLocation] = [] - - // MARK: - Private - - /// Async locations manager - private let manager : LocationManagerAsync - - /// Check status and get stream of async data - private var getStream : AsyncStream?{ - get async throws { - if await getStatus{ - return try manager.locations - } - throw LocationManagerErrors.accessIsNotAuthorized - } - } - - /// Get status - private var getStatus: Bool{ - get async{ - let isAuthorized = await manager.requestPermission() - return check(status: isAuthorized) - } - } - - // MARK: - Life circle - - public init(accuracy : CLLocationAccuracy? = nil){ - manager = LocationManagerAsync(accuracy: accuracy) - - } - - deinit{ - #if DEBUG - print("deinit LocationManagerViewModel") - #endif - } - - // MARK: - API - - /// Start streaming locations - public func start() async throws{ - if let stream = try await getStream{ - for await coordinate in stream{ - await update(coordinate: coordinate) - } - } - } - - /// Start streaming locations - public func stop(){ - manager.stop() - } - - // MARK: - Private - - - @MainActor - private func update(coordinate : CLLocation) { - locations.append(coordinate) - } - - private func check(status : CLAuthorizationStatus) -> Bool{ - [CLAuthorizationStatus.authorizedWhenInUse, .authorizedAlways].contains(status) - } - -} diff --git a/img/apple_watch_swiftui.gif b/img/apple_watch_swiftui.gif new file mode 100644 index 0000000..2af4bed Binary files /dev/null and b/img/apple_watch_swiftui.gif differ diff --git a/img/image11.gif b/img/image11.gif new file mode 100644 index 0000000..b44359c Binary files /dev/null and b/img/image11.gif differ diff --git a/img/image3.gif b/img/image3.gif deleted file mode 100644 index ac529c2..0000000 Binary files a/img/image3.gif and /dev/null differ diff --git a/img/image6.png b/img/image6.png new file mode 100644 index 0000000..073ddc0 Binary files /dev/null and b/img/image6.png differ