diff --git a/Examples/LocationManager/Common/AppCore.swift b/Examples/LocationManager/Common/AppCore.swift index f6d4588..0e4356a 100644 --- a/Examples/LocationManager/Common/AppCore.swift +++ b/Examples/LocationManager/Common/AppCore.swift @@ -18,210 +18,228 @@ public struct PointOfInterest: Equatable, Hashable { } } -public struct AppState: Equatable { - public var alert: AlertState? - public var isRequestingCurrentLocation = false - public var pointOfInterestCategory: MKPointOfInterestCategory? - public var pointsOfInterest: [PointOfInterest] = [] - public var region: CoordinateRegion? - - public init( - alert: AlertState? = nil, - isRequestingCurrentLocation: Bool = false, - pointOfInterestCategory: MKPointOfInterestCategory? = nil, - pointsOfInterest: [PointOfInterest] = [], - region: CoordinateRegion? = nil - ) { - self.alert = alert - self.isRequestingCurrentLocation = isRequestingCurrentLocation - self.pointOfInterestCategory = pointOfInterestCategory - self.pointsOfInterest = pointsOfInterest - self.region = region - } - - public static let pointOfInterestCategories: [MKPointOfInterestCategory] = [ - .cafe, - .museum, - .nightlife, - .park, - .restaurant, - ] +enum CancelID: Int { + case locationManager + case search } -public enum AppAction: Equatable { - case categoryButtonTapped(MKPointOfInterestCategory) - case currentLocationButtonTapped - case dismissAlertButtonTapped - case localSearchResponse(Result) - case locationManager(LocationManager.Action) - case onAppear - case onDisappear - case updateRegion(CoordinateRegion?) -} - -public struct AppEnvironment { - public var localSearch: LocalSearchClient - public var locationManager: LocationManager - - public init( - localSearch: LocalSearchClient, - locationManager: LocationManager - ) { - self.localSearch = localSearch - self.locationManager = locationManager - } -} - -private struct LocationManagerId: Hashable {} -private struct CancelSearchId: Hashable {} - -public let appReducer = AnyReducer { - state, action, environment in - switch action { - case let .categoryButtonTapped(category): - guard category != state.pointOfInterestCategory else { - state.pointOfInterestCategory = nil - state.pointsOfInterest = [] - return .cancel(id: CancelSearchId()) - } - - state.pointOfInterestCategory = category - - let request = MKLocalSearch.Request() - request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category]) - if let region = state.region?.asMKCoordinateRegion { - request.region = region - } - return environment.localSearch - .search(request) - .catchToEffect() - .map(AppAction.localSearchResponse) - .cancellable(id: CancelSearchId(), cancelInFlight: true) - - case .currentLocationButtonTapped: - guard environment.locationManager.locationServicesEnabled() else { - state.alert = .init(title: TextState("Location services are turned off.")) - return .none +public struct App: Reducer { + + public struct State: Equatable { + @PresentationState public var alert: AlertState? + public var isRequestingCurrentLocation = false + public var pointOfInterestCategory: MKPointOfInterestCategory? + public var pointsOfInterest: [PointOfInterest] = [] + public var region: CoordinateRegion? + + public init( + alert: AlertState? = nil, + isRequestingCurrentLocation: Bool = false, + pointOfInterestCategory: MKPointOfInterestCategory? = nil, + pointsOfInterest: [PointOfInterest] = [], + region: CoordinateRegion? = nil + ) { + self.alert = alert + self.isRequestingCurrentLocation = isRequestingCurrentLocation + self.pointOfInterestCategory = pointOfInterestCategory + self.pointsOfInterest = pointsOfInterest + self.region = region } - switch environment.locationManager.authorizationStatus() { - case .notDetermined: - state.isRequestingCurrentLocation = true - #if os(macOS) - return environment.locationManager - .requestAlwaysAuthorization() - .fireAndForget() - #else - return environment.locationManager - .requestWhenInUseAuthorization() - .fireAndForget() - #endif - - case .restricted: - state.alert = .init(title: TextState("Please give us access to your location in settings.")) - return .none - - case .denied: - state.alert = .init(title: TextState("Please give us access to your location in settings.")) - return .none - - case .authorizedAlways, .authorizedWhenInUse: - return environment.locationManager - .requestLocation() - .fireAndForget() - - @unknown default: - return .none - } - - case .dismissAlertButtonTapped: - state.alert = nil - return .none - - case let .localSearchResponse(.success(response)): - state.pointsOfInterest = response.mapItems.map { item in - PointOfInterest( - coordinate: item.placemark.coordinate, - subtitle: item.placemark.subtitle, - title: item.name - ) + public static let pointOfInterestCategories: [MKPointOfInterestCategory] = [ + .cafe, + .museum, + .nightlife, + .park, + .restaurant, + ] + } + + public enum Action: Equatable { + case task + case categoryButtonTapped(MKPointOfInterestCategory) + case currentLocationButtonTapped + case localSearchResponse(TaskResult) + case locationManager(LocationManager.Action) + case updateRegion(CoordinateRegion?) + case startRequestingCurrentLocation + case setAlert(AlertState?) + case alert(PresentationAction) + + public enum Alert: Equatable { + case dismissButtonTapped } - return .none - - case .localSearchResponse(.failure): - state.alert = .init(title: TextState("Could not perform search. Please try again.")) - return .none - - case .locationManager: - return .none - - case .onAppear: - return environment.locationManager.delegate() - .map(AppAction.locationManager) - .cancellable(id: LocationManagerId()) - - case .onDisappear: - return .cancel(id: LocationManagerId()) - - case let .updateRegion(region): - state.region = region - - guard - let category = state.pointOfInterestCategory, - let region = state.region?.asMKCoordinateRegion - else { return .none } - - let request = MKLocalSearch.Request() - request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category]) - request.region = region - return environment.localSearch - .search(request) - .catchToEffect() - .map(AppAction.localSearchResponse) - .cancellable(id: CancelSearchId(), cancelInFlight: true) } -} -.combined( - with: - locationManagerReducer - .pullback(state: \.self, action: /AppAction.locationManager, environment: { $0 }) -) -.signpost() -.debug() - -private let locationManagerReducer = AnyReducer { - state, action, environment in - - switch action { - case .didChangeAuthorization(.authorizedAlways), - .didChangeAuthorization(.authorizedWhenInUse): - if state.isRequestingCurrentLocation { - return environment.locationManager - .requestLocation() - .fireAndForget() + + @Dependency(\.localSearchClient) var localSearch + @Dependency(\.locationManager) var locationManager + + public var body: some ReducerOf { + CombineReducers { + location + + Reduce { state, action in + switch action { + case .task: + return .run { send in + await withTaskGroup(of: Void.self) { group in + group.addTask { + await withTaskCancellation(id: CancelID.locationManager, cancelInFlight: true) { + for await action in await locationManager.delegate() { + await send(.locationManager(action), animation: .default) + } + } + } + } + } + + case let .categoryButtonTapped(category): + guard category != state.pointOfInterestCategory else { + state.pointOfInterestCategory = nil + state.pointsOfInterest = [] + return .cancel(id: CancelID.search) + } + + state.pointOfInterestCategory = category + + let request = MKLocalSearch.Request() + request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category]) + if let region = state.region?.asMKCoordinateRegion { + request.region = region + } + + return .run { send in + await send( + .localSearchResponse( + TaskResult { + try await localSearch.search(request) + } + ) + ) + } + .cancellable(id: CancelID.search, cancelInFlight: true) + + case .currentLocationButtonTapped: + return .run { send in + guard await locationManager.locationServicesEnabled() else { + await send(.setAlert(.init(title: TextState("Location services are turned off.")))) + return + } + + switch await locationManager.authorizationStatus() { + case .notDetermined: + await send(.startRequestingCurrentLocation) + + case .restricted: + await send(.setAlert(.init(title: TextState("Please give us access to your location in settings.")))) + + case .denied: + await send(.setAlert(.init(title: TextState("Please give us access to your location in settings.")))) + + case .authorizedAlways, .authorizedWhenInUse: + await locationManager.requestLocation() + + @unknown default: + break + } + } + + case .startRequestingCurrentLocation: + state.isRequestingCurrentLocation = true + return .run { send in + #if os(macOS) + await locationManager.requestAlwaysAuthorization() + #else + await locationManager.requestWhenInUseAuthorization() + #endif + } + + case let .localSearchResponse(.success(response)): + state.pointsOfInterest = response.mapItems.map { item in + PointOfInterest( + coordinate: item.placemark.coordinate, + subtitle: item.placemark.subtitle, + title: item.name + ) + } + return .none + + case .localSearchResponse(.failure(let error)): + #if DEBUG + state.alert = .init(title: TextState(error.localizedDescription)) + #else + state.alert = .init(title: TextState("Could not perform search. Please try again.")) + #endif + return .none + + case .locationManager: + return .none + + case let .updateRegion(region): + state.region = region + + guard + let category = state.pointOfInterestCategory, + let region = state.region?.asMKCoordinateRegion + else { return .none } + + let request = MKLocalSearch.Request() + request.pointOfInterestFilter = MKPointOfInterestFilter(including: [category]) + request.region = region + return .run { send in + await send( + .localSearchResponse( + TaskResult { + try await localSearch.search(request) + } + ) + ) + } + .cancellable(id: CancelID.search, cancelInFlight: true) + + case .setAlert(let alert): + state.alert = alert + return .none + case .alert: + return .none + } + } } - return .none - - case .didChangeAuthorization(.denied): - if state.isRequestingCurrentLocation { - state.alert = .init( - title: TextState("Location makes this app better. Please consider giving us access.") - ) - state.isRequestingCurrentLocation = false + .ifLet(\.$alert, action: /Action.alert) + .signpost() + ._printChanges() + } + + @ReducerBuilder + var location: some ReducerOf { + Reduce { state, action in + switch action { + case .locationManager(.didChangeAuthorization(.authorizedAlways)), + .locationManager(.didChangeAuthorization(.authorizedWhenInUse)): + return state.isRequestingCurrentLocation ? .run { _ in await locationManager.requestLocation() } : .none + + case .locationManager(.didChangeAuthorization(.denied)): + if state.isRequestingCurrentLocation { + state.alert = .init( + title: TextState("Location makes this app better. Please consider giving us access.") + ) + state.isRequestingCurrentLocation = false + } + return .none + + case .locationManager(.didUpdateLocations(let locations)): + state.isRequestingCurrentLocation = false + guard let location = locations.first else { return .none } + state.region = CoordinateRegion( + center: location.coordinate, + span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) + ) + return .none + + default: + return .none + } } - return .none - - case let .didUpdateLocations(locations): - state.isRequestingCurrentLocation = false - guard let location = locations.first else { return .none } - state.region = CoordinateRegion( - center: location.coordinate, - span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) - ) - return .none - - default: - return .none } } diff --git a/Examples/LocationManager/Common/LocalSearchClient/Client.swift b/Examples/LocationManager/Common/LocalSearchClient/Client.swift index 6dbe51d..c37868b 100644 --- a/Examples/LocationManager/Common/LocalSearchClient/Client.swift +++ b/Examples/LocationManager/Common/LocalSearchClient/Client.swift @@ -1,16 +1,31 @@ -import ComposableArchitecture import MapKit +import Dependencies + +extension DependencyValues { + public var localSearchClient: LocalSearchClient { + get { self[LocalSearchClient.self] } + set { self[LocalSearchClient.self] = newValue } + } +} + +extension LocalSearchClient: TestDependencyKey { + public static let previewValue = Self.noop + public static let testValue = Self.failing +} + +extension LocalSearchClient { + public static let noop = Self( + search: { _ in try await Task.never() } + ) +} public struct LocalSearchClient { - public var search: (MKLocalSearch.Request) -> EffectPublisher + public var search: @Sendable (MKLocalSearch.Request) async throws -> LocalSearchResponse public init( - search: @escaping (MKLocalSearch.Request) -> EffectPublisher + search: @escaping @Sendable (MKLocalSearch.Request) async throws -> LocalSearchResponse ) { self.search = search } - - public struct Error: Swift.Error, Equatable { - public init() {} - } } + diff --git a/Examples/LocationManager/Common/LocalSearchClient/Failing.swift b/Examples/LocationManager/Common/LocalSearchClient/Failing.swift index 298110d..fae1f5f 100644 --- a/Examples/LocationManager/Common/LocalSearchClient/Failing.swift +++ b/Examples/LocationManager/Common/LocalSearchClient/Failing.swift @@ -1,8 +1,8 @@ -import ComposableArchitecture +import XCTestDynamicOverlay import MapKit extension LocalSearchClient { public static let failing = Self( - search: { _ in .failing("LocalSearchClient.search") } + search: { _ in unimplemented("LocalSearchClient.search") } ) } diff --git a/Examples/LocationManager/Common/LocalSearchClient/Live.swift b/Examples/LocationManager/Common/LocalSearchClient/Live.swift index cbd9fe6..79780d6 100644 --- a/Examples/LocationManager/Common/LocalSearchClient/Live.swift +++ b/Examples/LocationManager/Common/LocalSearchClient/Live.swift @@ -2,22 +2,13 @@ import Combine import ComposableArchitecture import MapKit -extension LocalSearchClient { - public static let live = LocalSearchClient( - search: { request in - EffectPublisher.future { callback in - MKLocalSearch(request: request).start { response, error in - switch (response, error) { - case let (.some(response), _): - callback(.success(LocalSearchResponse(response: response))) - case (_, .some): - callback(.failure(LocalSearchClient.Error())) - case (.none, .none): - fatalError("It should not be possible that response and error are both nil.") - } +extension LocalSearchClient: DependencyKey { + public static let liveValue = Self( + search: { request in + let response = try await MKLocalSearch(request: request).start() + return LocalSearchResponse(response: response) } - } - }) + ) } diff --git a/Examples/LocationManager/Common/MapView.swift b/Examples/LocationManager/Common/MapView.swift index ede643f..6e97e43 100644 --- a/Examples/LocationManager/Common/MapView.swift +++ b/Examples/LocationManager/Common/MapView.swift @@ -90,6 +90,8 @@ public class MapViewCoordinator: NSObject, MKMapViewDelegate { } public func mapView(_ mapView: MKMapView, regionDidChangeAnimated animated: Bool) { - self.mapView.region = CoordinateRegion(coordinateRegion: mapView.region) + DispatchQueue.main.async { + self.mapView.region = CoordinateRegion(coordinateRegion: mapView.region) + } } } diff --git a/Examples/LocationManager/LocationManager.xcodeproj/project.pbxproj b/Examples/LocationManager/LocationManager.xcodeproj/project.pbxproj index e8ed6e5..8aea16a 100644 --- a/Examples/LocationManager/LocationManager.xcodeproj/project.pbxproj +++ b/Examples/LocationManager/LocationManager.xcodeproj/project.pbxproj @@ -318,6 +318,9 @@ Base, ); mainGroup = CA17CC0024720BBA00BDDF11; + packageReferences = ( + 6AD0AA612AB2F8A4006D9BEA /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, + ); productRefGroup = CA17CC0A24720BBA00BDDF11 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -491,7 +494,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; @@ -547,7 +550,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; @@ -563,13 +566,14 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 463J8W9QEQ; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Mobile/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = co.pointfree.LocationManager; + PRODUCT_BUNDLE_IDENTIFIER = dev.casula.LocationManager; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; @@ -581,6 +585,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CODE_SIGN_STYLE = Automatic; + DEVELOPMENT_TEAM = 463J8W9QEQ; ENABLE_PREVIEWS = YES; INFOPLIST_FILE = Mobile/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( @@ -768,6 +773,17 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 6AD0AA612AB2F8A4006D9BEA /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.2.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ CA17CC3824720BEB00BDDF11 /* ComposableArchitecture */ = { isa = XCSwiftPackageProductDependency; diff --git a/Examples/LocationManager/LocationManager.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Examples/LocationManager/LocationManager.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 87e4e57..bb3407f 100644 --- a/Examples/LocationManager/LocationManager.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Examples/LocationManager/LocationManager.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -5,8 +5,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/combine-schedulers", "state" : { - "revision" : "ec62f32d21584214a4b27c8cee2b2ad70ab2c38a", - "version" : "0.11.0" + "revision" : "9dc9cbe4bc45c65164fa653a563d8d8db61b09bb", + "version" : "1.0.0" } }, { @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-case-paths", "state" : { - "revision" : "fc45e7b2cfece9dd80b5a45e6469ffe67fe67984", - "version" : "0.14.1" + "revision" : "5da6989aae464f324eef5c5b52bdb7974725ab81", + "version" : "1.0.0" } }, { @@ -23,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-clocks", "state" : { - "revision" : "0fbaebfc013715dab44d715a4d350ba37f297e4d", - "version" : "0.4.0" + "revision" : "d1fd837326aa719bee979bdde1f53cd5797443eb", + "version" : "1.0.0" } }, { @@ -39,10 +39,10 @@ { "identity" : "swift-composable-architecture", "kind" : "remoteSourceControl", - "location" : "https://github.com/pointfreeco/swift-composable-architecture", + "location" : "git@github.com:pointfreeco/swift-composable-architecture.git", "state" : { - "revision" : "9f4202ab5b8422aa90f0ed983bf7652c3af7abf0", - "version" : "0.59.0" + "revision" : "a7c1f799b55ecb418f85094b142565834f7ee7c7", + "version" : "1.2.0" } }, { @@ -50,8 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-concurrency-extras", "state" : { - "revision" : "479750bd98fac2e813fffcf2af0728b5b0085795", - "version" : "0.1.1" + "revision" : "ea631ce892687f5432a833312292b80db238186a", + "version" : "1.0.0" } }, { @@ -59,8 +59,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-custom-dump", "state" : { - "revision" : "4a87bb75be70c983a9548597e8783236feb3401e", - "version" : "0.11.1" + "revision" : "edd66cace818e1b1c6f1b3349bb1d8e00d6f8b01", + "version" : "1.0.0" } }, { @@ -68,8 +68,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-dependencies", "state" : { - "revision" : "16fd42ae04c6e7f74a6a86395d04722c641cccee", - "version" : "0.6.0" + "revision" : "4e1eb6e28afe723286d8cc60611237ffbddba7c5", + "version" : "1.0.0" } }, { @@ -77,8 +77,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swift-identified-collections", "state" : { - "revision" : "d01446a78fb768adc9a78cbb6df07767c8ccfc29", - "version" : "0.8.0" + "revision" : "d1e45f3e1eee2c9193f5369fa9d70a6ddad635e8", + "version" : "1.0.0" } }, { @@ -86,8 +86,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/swiftui-navigation", "state" : { - "revision" : "2aa885e719087ee19df251c08a5980ad3e787f12", - "version" : "0.8.0" + "revision" : "6eb293c49505d86e9e24232cb6af6be7fff93bd5", + "version" : "1.0.2" } }, { @@ -95,8 +95,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", "state" : { - "revision" : "50843cbb8551db836adec2290bb4bc6bac5c1865", - "version" : "0.9.0" + "revision" : "23cbf2294e350076ea4dbd7d5d047c1e76b03631", + "version" : "1.0.2" } } ], diff --git a/Examples/LocationManager/Mobile/LocationManagerView.swift b/Examples/LocationManager/Mobile/LocationManagerView.swift index 8d030d2..277bd96 100644 --- a/Examples/LocationManager/Mobile/LocationManagerView.swift +++ b/Examples/LocationManager/Mobile/LocationManagerView.swift @@ -14,14 +14,14 @@ private let readMe = """ struct LocationManagerView: View { @Environment(\.colorScheme) var colorScheme - let store: Store + let store: StoreOf var body: some View { - WithViewStore(self.store) { viewStore in + WithViewStore(self.store, observe: { $0 }) { viewStore in ZStack { MapView( pointsOfInterest: viewStore.pointsOfInterest, - region: viewStore.binding(get: { $0.region }, send: AppAction.updateRegion) + region: viewStore.binding(get: \.region, send: App.Action.updateRegion) ) .edgesIgnoringSafeArea([.all]) @@ -40,7 +40,7 @@ struct LocationManagerView: View { ScrollView(.horizontal, showsIndicators: false) { HStack(spacing: 16) { - ForEach(AppState.pointOfInterestCategories, id: \.rawValue) { category in + ForEach(App.State.pointOfInterestCategories, id: \.rawValue) { category in Button(category.displayName) { viewStore.send(.categoryButtonTapped(category)) } .padding([.all], 16) .background( @@ -55,9 +55,8 @@ struct LocationManagerView: View { } } } - .alert(self.store.scope(state: { $0.alert }), dismiss: .dismissAlertButtonTapped) - .onAppear { viewStore.send(.onAppear) } - .onDisappear { viewStore.send(.onDisappear) } + .task { await viewStore.send(.task).finish() } + .alert(store: self.store.scope(state: \.$alert, action: App.Action.alert)) } } } @@ -75,10 +74,12 @@ struct ContentView: View { "Go to demo", destination: LocationManagerView( store: Store( - initialState: AppState(), - reducer: appReducer, - environment: AppEnvironment(localSearch: .live, locationManager: .live) - ) + initialState: App.State() + ) { + App() + .dependency(\.localSearchClient, .liveValue) + .dependency(\.locationManager, .live) + } ) ) } @@ -90,32 +91,19 @@ struct ContentView: View { } #if DEBUG + + + struct ContentView_Previews: PreviewProvider { static var previews: some View { - // NB: CLLocationManager mostly does not work in SwiftUI previews, so we provide a mock - // manager that has all authorization allowed and mocks the device's current location - // to Brooklyn, NY. - let mockLocation = Location( - coordinate: CLLocationCoordinate2D(latitude: 40.6501, longitude: -73.94958) - ) - let locationManagerSubject = PassthroughSubject() - var locationManager = LocationManager.live - locationManager.authorizationStatus = { .authorizedAlways } - locationManager.delegate = { locationManagerSubject.eraseToEffect() } - locationManager.locationServicesEnabled = { true } - locationManager.requestLocation = { - .fireAndForget { locationManagerSubject.send(.didUpdateLocations([mockLocation])) } - } - let appView = LocationManagerView( store: Store( - initialState: AppState(), - reducer: appReducer, - environment: AppEnvironment( - localSearch: .live, - locationManager: locationManager - ) - ) + initialState: App.State() + ) { + App() + .dependency(\.localSearchClient, .liveValue) + .dependency(\.locationManager, .mock()) + } ) return Group { @@ -126,4 +114,65 @@ struct ContentView: View { } } } + +extension LocationManager { + + static func mock() -> Self { + actor MockStore { + let locationManagerSubject: CurrentValueSubject + var currentAuthorizationStatus: CLAuthorizationStatus { + didSet { + locationManagerSubject.send(.didChangeAuthorization(currentAuthorizationStatus)) + } + } + + var currentLocation: ComposableCoreLocation.Location? { + didSet { + locationManagerSubject.send( + .didUpdateLocations(currentLocation.map { [$0] } ?? []) + ) + } + } + + init(authorization: CLAuthorizationStatus) { + self.currentAuthorizationStatus = authorization + self.locationManagerSubject = .init(.didChangeAuthorization(currentAuthorizationStatus)) + } + + func update(authorization: CLAuthorizationStatus) { + self.currentAuthorizationStatus = authorization + } + + func update(location: ComposableCoreLocation.Location) { + self.currentLocation = location + } + } + + // NB: CLLocationManager mostly does not work in SwiftUI previews, so we provide a mock + // manager that has all authorization allowed and mocks the device's current location + // to Brooklyn, NY. + let mockLocation = Location( + coordinate: CLLocationCoordinate2D(latitude: 40.6501, longitude: -73.94958) + ) + let store = MockStore(authorization: .authorizedAlways) + var manager = LocationManager.live + + manager.delegate = { + AsyncStream { continuation in + let cancellable = store.locationManagerSubject.sink { action in + continuation.yield(action) + } + continuation.onTermination = { _ in + cancellable.cancel() + } + } + } + manager.locationServicesEnabled = { true } + manager.requestLocation = { + await store.update(location: mockLocation) + } + return manager + } +} + #endif