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/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