├── diagrams ├── movies.gif ├── signin.png ├── counter.gif ├── traffic_light.gif ├── ReactiveFeedback.jpg └── ReactiveFeedback.xml ├── Example ├── Assets.xcassets │ ├── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── AppDelegate.swift ├── Preview Content │ └── Preview Assets.xcassets │ │ └── Contents.json ├── Helpers │ ├── Log.swift │ └── PublisherExtensions.swift ├── Views │ ├── StoreExtensions.swift │ ├── Activity.swift │ └── AsyncImage.swift ├── SingleStoreExample │ ├── Counter │ │ └── CounterState.swift │ ├── SingleStoreExampleView.swift │ ├── SwitchStoreExample │ │ └── SwitchStoreExample.swift │ ├── Movies │ │ ├── MoviesState.swift │ │ └── FavouriteMovies.swift │ ├── TrafficLight │ │ └── TrafficLightState.swift │ ├── State.swift │ └── Signin │ │ └── SigninState.swift ├── TrafficLight │ ├── TrafficLightView.swift │ └── TrafficLight.swift ├── CounterExample │ └── Counter.swift ├── Base.lproj │ └── LaunchScreen.storyboard ├── Info.plist ├── SceneDelegate.swift ├── MoviesExample │ └── Movies.swift └── SignIn │ └── SignIn.swift ├── ExampleTests ├── ExampleTests.swift └── Info.plist ├── CombineFeedback.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── xcshareddata │ └── xcschemes │ │ └── Example.xcscheme └── project.pbxproj ├── Sources └── CombineFeedback │ ├── Internal │ ├── NSLockExtensions.swift │ ├── FeedbackEventConsumer.swift │ ├── Atomic.swift │ └── Floodgate.swift │ ├── System.swift │ ├── Info.plist │ ├── FeedbackLoop.swift │ ├── FlatMapLatest.swift │ ├── SwiftUI │ ├── WithContextView.swift │ ├── IfLetStoreView.swift │ ├── ViewContext.swift │ └── SwitchStoreView.swift │ ├── Store │ ├── Store.swift │ └── StoreBox.swift │ ├── Reducer.swift │ └── Feedback.swift ├── .github └── workflows │ └── swift.yml ├── Package.resolved ├── Tests └── CombineFeedbackTests │ ├── Info.plist │ └── CombineFeedbackTests.swift ├── Package.swift ├── LICENSE ├── .gitignore └── README.md /diagrams/movies.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergdort/CombineFeedback/HEAD/diagrams/movies.gif -------------------------------------------------------------------------------- /diagrams/signin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergdort/CombineFeedback/HEAD/diagrams/signin.png -------------------------------------------------------------------------------- /diagrams/counter.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergdort/CombineFeedback/HEAD/diagrams/counter.gif -------------------------------------------------------------------------------- /diagrams/traffic_light.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergdort/CombineFeedback/HEAD/diagrams/traffic_light.gif -------------------------------------------------------------------------------- /diagrams/ReactiveFeedback.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergdort/CombineFeedback/HEAD/diagrams/ReactiveFeedback.jpg -------------------------------------------------------------------------------- /Example/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /ExampleTests/ExampleTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Example 3 | 4 | class ExampleTests: XCTestCase { 5 | } 6 | -------------------------------------------------------------------------------- /Example/AppDelegate.swift: -------------------------------------------------------------------------------- 1 | import UIKit 2 | 3 | @UIApplicationMain 4 | class AppDelegate: UIResponder, UIApplicationDelegate { 5 | } 6 | 7 | -------------------------------------------------------------------------------- /Example/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "version" : 1, 4 | "author" : "xcode" 5 | } 6 | } -------------------------------------------------------------------------------- /CombineFeedback.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Example/Helpers/Log.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | func logInit(of object: T) { 4 | print("Init of", type(of: object)) 5 | } 6 | 7 | func logBody(of view: T) { 8 | print("Body of", type(of: view)) 9 | } 10 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/Internal/NSLockExtensions.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension NSLock { 4 | func perform(_ action: () -> Result) -> Result { 5 | lock() 6 | defer { unlock() } 7 | 8 | return action() 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /CombineFeedback.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Example/Views/StoreExtensions.swift: -------------------------------------------------------------------------------- 1 | import CombineFeedback 2 | 3 | extension Store { 4 | static func empty(_ state: State) -> Store { 5 | Store(initial: state, feedbacks: [], reducer: .empty, dependency: ()) 6 | } 7 | } 8 | 9 | extension Reducer { 10 | static var empty: Reducer { 11 | Reducer(reduce: { _, _ in }) 12 | } 13 | } 14 | 15 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: macos-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Build 17 | run: swift build -v 18 | - name: Run tests 19 | run: swift test -v 20 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "CasePaths", 6 | "repositoryURL": "https://github.com/pointfreeco/swift-case-paths.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "a9c1e05518b6d95cf5844d823020376f2b6ff842", 10 | "version": "0.1.0" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Example/Helpers/PublisherExtensions.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | extension Publisher { 4 | public func replaceError( 5 | replace: @escaping (Failure) -> Self.Output 6 | ) -> AnyPublisher { 7 | return `catch` { error in 8 | Result.Publisher(replace(error)) 9 | }.eraseToAnyPublisher() 10 | } 11 | 12 | public func ignoreError() -> AnyPublisher { 13 | return `catch` { _ in 14 | Empty() 15 | }.eraseToAnyPublisher() 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Example/SingleStoreExample/Counter/CounterState.swift: -------------------------------------------------------------------------------- 1 | import CombineFeedback 2 | 3 | enum Counter { 4 | struct State: Equatable { 5 | var count = 0 6 | } 7 | 8 | enum Event { 9 | case increment 10 | case decrement 11 | } 12 | 13 | static func reducer() -> Reducer { 14 | .init { state, event in 15 | switch event { 16 | case .increment: 17 | state.count += 1 18 | case .decrement: 19 | state.count -= 1 20 | } 21 | } 22 | } 23 | } 24 | 25 | -------------------------------------------------------------------------------- /Example/Views/Activity.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct Spinner: UIViewRepresentable { 4 | typealias UIViewType = UIActivityIndicatorView 5 | 6 | var style: UIActivityIndicatorView.Style 7 | 8 | func makeUIView(context: UIViewRepresentableContext) -> UIActivityIndicatorView { 9 | let view = UIActivityIndicatorView(style: style) 10 | view.startAnimating() 11 | return view 12 | } 13 | 14 | func updateUIView(_ uiView: UIActivityIndicatorView, context: UIViewRepresentableContext) {} 15 | } 16 | 17 | #if DEBUG 18 | struct Activity_Previews : PreviewProvider { 19 | static var previews: some View { 20 | Spinner(style: .medium) 21 | } 22 | } 23 | #endif 24 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/System.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | 4 | public extension Publishers { 5 | static func system( 6 | initial: State, 7 | feedbacks: [Feedback], 8 | reduce: Reducer, 9 | dependency: Dependency 10 | ) -> AnyPublisher { 11 | return Publishers.FeedbackLoop( 12 | initial: initial, 13 | reduce: reduce, 14 | feedbacks: feedbacks, 15 | dependency: dependency 16 | ) 17 | .eraseToAnyPublisher() 18 | } 19 | } 20 | 21 | public extension Publisher where Output == Never, Failure == Never { 22 | func start() -> Cancellable { 23 | return sink(receiveValue: { _ in }) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /ExampleTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Tests/CombineFeedbackTests/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | $(CURRENT_PROJECT_VERSION) 21 | 22 | 23 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.1 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "CombineFeedback", 6 | platforms: [ 7 | .macOS(.v10_15), 8 | .iOS(.v13), 9 | .tvOS(.v13), 10 | .watchOS(.v6) 11 | ], 12 | products: [ 13 | .library(name: "CombineFeedback", targets: ["CombineFeedback"]), 14 | ], 15 | dependencies: [ 16 | .package( 17 | url: "https://github.com/pointfreeco/swift-case-paths.git", 18 | from: Version(0, 2, 0) 19 | ), 20 | .package( 21 | url: "https://github.com/pointfreeco/combine-schedulers.git", 22 | from: Version(0, 5, 0) 23 | ) 24 | ], 25 | targets: [ 26 | .target(name: "CombineFeedback", dependencies: ["CasePaths", "CombineSchedulers"]), 27 | .testTarget(name: "CombineFeedbackTests", dependencies: ["CombineFeedback"]) 28 | ], 29 | swiftLanguageVersions: [.v5] 30 | ) 31 | -------------------------------------------------------------------------------- /CombineFeedback.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "combine-schedulers", 6 | "repositoryURL": "https://github.com/pointfreeco/combine-schedulers.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "c37e5ae8012fb654af776cc556ff8ae64398c841", 10 | "version": "0.5.0" 11 | } 12 | }, 13 | { 14 | "package": "swift-case-paths", 15 | "repositoryURL": "git@github.com:pointfreeco/swift-case-paths.git", 16 | "state": { 17 | "branch": null, 18 | "revision": "a313f0cc10e07bb5ce7e2ff5da600cce7efa8e8a", 19 | "version": "0.2.0" 20 | } 21 | }, 22 | { 23 | "package": "xctest-dynamic-overlay", 24 | "repositoryURL": "https://github.com/pointfreeco/xctest-dynamic-overlay", 25 | "state": { 26 | "branch": null, 27 | "revision": "603974e3909ad4b48ba04aad7e0ceee4f077a518", 28 | "version": "0.1.0" 29 | } 30 | } 31 | ] 32 | }, 33 | "version": 1 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 sergdort 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Example/TrafficLight/TrafficLightView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CombineFeedback 3 | 4 | struct TrafficLightView: View { 5 | let store: Store 6 | 7 | init(store: Store) { 8 | self.store = store 9 | logInit(of: self) 10 | } 11 | 12 | var body: some View { 13 | WithContextView(store: store) { context in 14 | VStack { 15 | Circle() 16 | .fill(Color.red.opacity(context.isRed ? 1 : 0.5)) 17 | .frame(width: 150, height: 150) 18 | Circle() 19 | .fill(Color.yellow.opacity(context.isYellow ? 1 : 0.5)) 20 | .frame(width: 150, height: 150) 21 | Circle() 22 | .fill(Color.green.opacity(context.isGreen ? 1 : 0.5)) 23 | .frame(width: 150, height: 150) 24 | } 25 | .padding() 26 | .background(Color.black) 27 | } 28 | } 29 | } 30 | 31 | #if DEBUG 32 | struct TrafficLightView_Preview: PreviewProvider { 33 | static var previews: some View { 34 | TrafficLightView( 35 | store: .empty(.green) 36 | ) 37 | .previewLayout(.sizeThatFits) 38 | } 39 | } 40 | #endif 41 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/FeedbackLoop.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public extension Publishers { 4 | struct FeedbackLoop: Publisher { 5 | public typealias Failure = Never 6 | let initial: Output 7 | let reduce: Reducer 8 | let feedbacks: [Feedback] 9 | let dependency: Dependency 10 | 11 | public init( 12 | initial: Output, 13 | reduce: Reducer, 14 | feedbacks: [Feedback], 15 | dependency: Dependency 16 | ) { 17 | self.initial = initial 18 | self.reduce = reduce 19 | self.feedbacks = feedbacks 20 | self.dependency = dependency 21 | } 22 | 23 | public func receive(subscriber: S) where S: Combine.Subscriber, Failure == S.Failure, Output == S.Input { 24 | let floodgate = Floodgate( 25 | state: initial, 26 | feedbacks: feedbacks, 27 | sink: subscriber, 28 | reducer: reduce, 29 | dependency: dependency 30 | ) 31 | subscriber.receive(subscription: floodgate) 32 | floodgate.bootstrap() 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/FlatMapLatest.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | 3 | public extension Publisher { 4 | func flatMapLatest( 5 | _ transformation: @escaping (Self.Output) -> U 6 | ) -> Publishers.FlatMapLatest 7 | where U: Publisher, U.Failure == Self.Failure 8 | { 9 | return Publishers.FlatMapLatest(upstream: self, transform: transformation) 10 | } 11 | } 12 | 13 | public extension Publishers { 14 | struct FlatMapLatest: Publisher 15 | where P: Publisher, Upstream: Publisher, P.Failure == Upstream.Failure 16 | { 17 | public typealias Output = P.Output 18 | public typealias Failure = Upstream.Failure 19 | 20 | private let upstream: Upstream 21 | private let transform: (Upstream.Output) -> P 22 | 23 | init(upstream: Upstream, transform: @escaping (Upstream.Output) -> P) { 24 | self.upstream = upstream 25 | self.transform = transform 26 | } 27 | 28 | public func receive(subscriber: S) where S: Subscriber, P.Output == S.Input, Upstream.Failure == S.Failure { 29 | self.upstream.map(self.transform) 30 | .switchToLatest() 31 | .receive(subscriber: subscriber) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Example/CounterExample/Counter.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineFeedback 3 | import SwiftUI 4 | 5 | extension Counter { 6 | final class ViewModel: Store { 7 | init() { 8 | super.init( 9 | initial: State(), 10 | feedbacks: [], 11 | reducer: Counter.reducer(), 12 | dependency: () 13 | ) 14 | } 15 | } 16 | } 17 | 18 | struct CounterView: View { 19 | typealias State = Counter.State 20 | typealias Event = Counter.Event 21 | 22 | let store: Store 23 | 24 | init(store: Store) { 25 | self.store = store 26 | logInit(of: self) 27 | } 28 | 29 | var body: some View { 30 | WithContextView(store: store) { context in 31 | Form { 32 | Button(action: { 33 | context.send(event: .decrement) 34 | }) { 35 | Text("-").font(.largeTitle) 36 | } 37 | Button(action: { 38 | context.send(event: .increment) 39 | }) { 40 | Text("+").font(.largeTitle) 41 | } 42 | if context.count >= 0 { 43 | ForEach((0 ..< context.count).reversed(), id: \.self) { item in 44 | Text("\(item)") 45 | } 46 | } 47 | } 48 | } 49 | } 50 | } 51 | 52 | extension View { 53 | func eraseToAnyView() -> AnyView { 54 | return AnyView(self) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/SwiftUI/WithContextView.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import SwiftUI 3 | 4 | @available(*, deprecated, renamed: "WithContextView") 5 | public typealias Widget = WithContextView 6 | 7 | /// A helper view that bridges Store into SwiftUI world by using @ObservedObject ViewContext 8 | /// to listed to the state changes of the Store and render the UI 9 | public struct WithContextView: View { 10 | @ObservedObject 11 | private var context: ViewContext 12 | private let store: Store 13 | private let content: (ViewContext) -> Content 14 | 15 | public init( 16 | store: Store, 17 | removeDuplicates isDuplicate: @escaping (State, State) -> Bool, 18 | @ViewBuilder content: @escaping (ViewContext) -> Content 19 | ) { 20 | self.store = store 21 | self.content = content 22 | self.context = store.context(removeDuplicates: isDuplicate) 23 | } 24 | 25 | public var body: some View { 26 | return content(context) 27 | } 28 | } 29 | 30 | public extension WithContextView where State: Equatable { 31 | init( 32 | store: Store, 33 | @ViewBuilder content: @escaping (ViewContext) -> Content 34 | ) { 35 | self.init(store: store, removeDuplicates: ==, content: content) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/Internal/FeedbackEventConsumer.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | struct Token: Equatable { 4 | let value: UUID 5 | 6 | init() { 7 | value = UUID() 8 | } 9 | } 10 | 11 | public class FeedbackEventConsumer { 12 | func process(_ event: Event, for token: Token) { 13 | fatalError("This is an abstract class. You must subclass this and provide your own implementation") 14 | } 15 | 16 | func dequeueAllEvents(for token: Token) { 17 | fatalError("This is an abstract class. You must subclass this and provide your own implementation") 18 | } 19 | } 20 | 21 | extension FeedbackEventConsumer { 22 | func pullback(_ f: @escaping (LocalEvent) -> Event) -> FeedbackEventConsumer { 23 | return PullBackConsumer(upstream: self, pull: f) 24 | } 25 | } 26 | 27 | final class PullBackConsumer: FeedbackEventConsumer { 28 | private let upstream: FeedbackEventConsumer 29 | private let pull: (LocalEvent) -> Event 30 | 31 | init(upstream: FeedbackEventConsumer, pull: @escaping (LocalEvent) -> Event) { 32 | self.pull = pull 33 | self.upstream = upstream 34 | super.init() 35 | } 36 | 37 | override func process(_ event: LocalEvent, for token: Token) { 38 | self.upstream.process(pull(event), for: token) 39 | } 40 | 41 | override func dequeueAllEvents(for token: Token) { 42 | self.upstream.dequeueAllEvents(for: token) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/SwiftUI/IfLetStoreView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | public struct IfLetStoreView: View { 4 | private let store: Store 5 | private let content: (ViewContext) -> Content 6 | 7 | public init( 8 | store: Store, 9 | @ViewBuilder then ifContent: @escaping (Store) -> IfContent, 10 | @ViewBuilder else elseContent: @escaping () -> ElseContent 11 | ) where Content == _ConditionalContent { 12 | self.store = store 13 | self.content = { context in 14 | if let state = context[dynamicMember: \State.self] { 15 | return ViewBuilder.buildEither( 16 | first: ifContent( 17 | store.scope( 18 | getValue: { 19 | return $0 ?? state 20 | } 21 | ) 22 | ) 23 | ) 24 | } else { 25 | return ViewBuilder.buildEither(second: elseContent()) 26 | } 27 | } 28 | } 29 | 30 | public init( 31 | store: Store, 32 | @ViewBuilder then ifContent: @escaping (Store) -> IfContent 33 | ) where Content == _ConditionalContent { 34 | self.init(store: store, then: ifContent, else: EmptyView.init) 35 | } 36 | 37 | public var body: some View { 38 | WithContextView( 39 | store: self.store, 40 | removeDuplicates: { ($0 != nil) == ($1 != nil) }, 41 | content: content 42 | ) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /diagrams/ReactiveFeedback.xml: -------------------------------------------------------------------------------- 1 | 5Vlbc6M2FP41fozHAgvjxzjJNvvQnU6zbR93ZHQMagBRIYzTX18JiTveeFInzoz9YMtH5yK+75wjATP3Ljn8IkgW/copxDNnQQ8z937mOCvfU99a8GIES+wbQSgYNSLUCp7Yv2CFCystGIW8pyg5jyXL+sKApykEsicjQvCyr7bjcT9qRkIYCZ4CEo+lfzEqIyP1nVUrfwQWRnVk5K3NzJYEz6HgRWrjzRx3V33MdEJqX/ZC84hQXnZE7sPMvROcSzNKDncQa2hr2IzdlyOzzboFpPIUg6Ux2JO4gHrFXqxMNzuuPKgFyhcLivdPweuJm7yi7FYpIJwd2kk1CvXv15RJRnSkJ0kk1E7VQoxfo2UxaEI4uRT8uYFcobGJZBKrIVJDhVWm9ZJDqJNuvot5GUREyHku1fcPrVNGTMJTRgKtWCo1vWAWx3c85qKKofgALwi0vypYZ4au1tvFolnXHoSEw1FgUUOXqgLgCUjxolSsgethY2IrAK0s42WbT2hhZVEnlzwrIzaFw8Z1S6MaWCanWcVHWaVsP0mqulB5Q2IWpobVGHZyzOoXAKozvENn5fC8bGaCB5Dnp7FJMfh0OcWm72xdzzsTm3W9WjZdPGbTmWKzofj/0OlN0DmAug+saSk6kynJI6D2j+X3PlBXDQqljcaEqa53aycSRqn2OCLH9FTH/Gq3GofFXKNb9fxlFYsJ1YsZ155yXmhcNimXQWTDD+vQD2C6Drc+XuJz1eG6z5wzwRxGY+L8M/CG0Kchbgspve2Sh3DNnesPeKpsHoHo6FirVftZs5hdzLI/O+NHu/wp+i9COfIHxXpq6z1LsSLnnbbUhz1UO+en2UgpAX83yaYX+LDdnYlNr8/mcv2BGyk6fj7KM5K+nczfgRaBKuWWTuPvCnZS1KPTQxN0rtfvVZxTJ6Or6Mgl5PKzNGTsf2hDnjo+naMhf4Pyeu9vsL+8YFteXWsZp1xc7mCFvX7vxr7zkXXsXyvpQC7XuzG+5GHacV/n/I09laSKJaIR/uF0kqJ68HGmDun1kGueD3TvPN8NuOMH1yJutycjiVkt+eOrsgF9o5HXk1sxVFexW4uxj28gSy6eWRqeaPCdJSDeHm+jrlByriB9q4f5fP5TTSXsgjbIP/3orJ90AtTRgWwrBV2GGWcaUKWNNzN8r7OtkNwcLyqDQfIN+5HkmbnhCBSo3/Wf+5vleZLUQ+jVJEXOu2XpCcfxttWqzS+FPtJ/F0lm3x54TSfstgLjDujocf+rAL1SpbVMQKyayL7vfgoTG+E3nQudJjF8sLgcwJrzQgRgrVpkx46G3QYPHKkjZAhy5KiiqLnsKdbU3/Z9hFFv3/m4D/8B -------------------------------------------------------------------------------- /Example/SingleStoreExample/SingleStoreExampleView.swift: -------------------------------------------------------------------------------- 1 | import CombineFeedback 2 | import SwiftUI 3 | 4 | struct SingleStoreExampleView: View { 5 | let store: Store 6 | 7 | init(store: Store) { 8 | self.store = store 9 | logInit(of: self) 10 | } 11 | 12 | var body: some View { 13 | TabView { 14 | NavigationView { 15 | CounterView( 16 | store: store.scoped(to: \.counter, event: Event.counter) 17 | ) 18 | .navigationBarTitle(Text("Counter")) 19 | } 20 | .tabItem { 21 | Image(systemName: "eye") 22 | } 23 | NavigationView { 24 | SwitchStoreExample.RootView( 25 | store: store.scoped(to: \.switchExample, event: Event.switchExample) 26 | ) 27 | .navigationBarTitle(Text("Switch Store")) 28 | } 29 | .tabItem { 30 | Image(systemName: "switch.2") 31 | } 32 | NavigationView { 33 | FavouriteMovies.RootView( 34 | store: store.scoped(to: \.favouriteMovies, event: Event.favouriteMovies) 35 | ) 36 | .navigationBarTitle(Text("Parent Child State")) 37 | } 38 | .tabItem { 39 | Image(systemName: "film") 40 | } 41 | NavigationView { 42 | SignInView(store: store.scoped(to: \.signIn, event: Event.signIn)) 43 | .navigationBarTitle(Text("Form Example")) 44 | } 45 | .tabItem { 46 | Image(systemName: "person") 47 | } 48 | NavigationView { 49 | TrafficLightView(store: store.scoped(to: \.traficLight, event: Event.trafficLight)) 50 | .navigationBarTitle(Text("Non UI Effects")) 51 | } 52 | .tabItem { 53 | Image(systemName: "tortoise") 54 | } 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Example/Base.lproj/LaunchScreen.storyboard: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/SwiftUI/ViewContext.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import CombineSchedulers 4 | 5 | @available(*, deprecated, renamed: "ViewContext") 6 | public typealias Context = ViewContext 7 | 8 | @dynamicMemberLookup 9 | public final class ViewContext: ObservableObject { 10 | @Published 11 | private var state: State 12 | private var bag = Set() 13 | private let send: (Event) -> Void 14 | 15 | init( 16 | store: StoreBoxBase, 17 | removeDuplicates isDuplicate: @escaping (State, State) -> Bool 18 | ) { 19 | self.state = store._current 20 | self.send = store.send 21 | store.publisher 22 | .removeDuplicates(by: isDuplicate) 23 | .receive(on: UIScheduler.shared, options: nil) 24 | .assign(to: \.state, weakly: self) 25 | .store(in: &bag) 26 | } 27 | 28 | public subscript(dynamicMember keyPath: KeyPath) -> U { 29 | return state[keyPath: keyPath] 30 | } 31 | 32 | public func send(event: Event) { 33 | send(event) 34 | } 35 | 36 | public func binding(for keyPath: KeyPath, event: @escaping (U) -> Event) -> Binding { 37 | return Binding( 38 | get: { 39 | self.state[keyPath: keyPath] 40 | }, 41 | set: { 42 | self.send(event: event($0)) 43 | } 44 | ) 45 | } 46 | 47 | public func binding(for keyPath: KeyPath, event: Event) -> Binding { 48 | return Binding( 49 | get: { 50 | self.state[keyPath: keyPath] 51 | }, 52 | set: { _ in 53 | self.send(event: event) 54 | } 55 | ) 56 | } 57 | 58 | public func action(for event: Event) -> () -> Void { 59 | return { 60 | self.send(event: event) 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/Internal/Atomic.swift: -------------------------------------------------------------------------------- 1 | /// An atomic variable. 2 | import Foundation 3 | 4 | final class Atomic { 5 | private let lock: NSLock 6 | private var _value: Value 7 | 8 | /// Atomically get or set the value of the variable. 9 | var value: Value { 10 | get { 11 | return withValue { $0 } 12 | } 13 | 14 | set(newValue) { 15 | swap(newValue) 16 | } 17 | } 18 | 19 | /// Initialize the variable with the given initial value. 20 | /// 21 | /// - parameters: 22 | /// - value: Initial value for `self`. 23 | init(_ value: Value) { 24 | _value = value 25 | lock = NSLock() 26 | } 27 | 28 | /// Atomically modifies the variable. 29 | /// 30 | /// - parameters: 31 | /// - action: A closure that takes the current value. 32 | /// 33 | /// - returns: The result of the action. 34 | @discardableResult 35 | func modify(_ action: (inout Value) throws -> Result) rethrows -> Result { 36 | lock.lock() 37 | defer { lock.unlock() } 38 | 39 | return try action(&_value) 40 | } 41 | 42 | /// Atomically perform an arbitrary action using the current value of the 43 | /// variable. 44 | /// 45 | /// - parameters: 46 | /// - action: A closure that takes the current value. 47 | /// 48 | /// - returns: The result of the action. 49 | @discardableResult 50 | func withValue(_ action: (Value) throws -> Result) rethrows -> Result { 51 | lock.lock() 52 | defer { lock.unlock() } 53 | 54 | return try action(_value) 55 | } 56 | 57 | /// Atomically replace the contents of the variable. 58 | /// 59 | /// - parameters: 60 | /// - newValue: A new value for the variable. 61 | /// 62 | /// - returns: The old value. 63 | @discardableResult 64 | func swap(_ newValue: Value) -> Value { 65 | return modify { (value: inout Value) in 66 | let oldValue = value 67 | value = newValue 68 | return oldValue 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Example/SingleStoreExample/SwitchStoreExample/SwitchStoreExample.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CombineFeedback 3 | import Combine 4 | import CasePaths 5 | import SwiftUI 6 | 7 | enum SwitchStoreExample { 8 | struct RootView: View { 9 | let store: Store 10 | 11 | var body: some View { 12 | SwitchStoreView(store: store) { state in 13 | switch state { 14 | case .signIn: 15 | CaseLetStoreView(state: /State.signIn, action: Event.signIn) { store in 16 | SignInView(store: store) 17 | } 18 | case .counter: 19 | CaseLetStoreView(state: /State.counter, action: Event.counter) { store in 20 | CounterView(store: store) 21 | } 22 | } 23 | } 24 | } 25 | } 26 | 27 | static var reducer: Reducer { 28 | Reducer.combine( 29 | SignIn.reducer() 30 | .pullback( 31 | state: /State.signIn, 32 | event: /Event.signIn 33 | ), 34 | Counter.reducer() 35 | .pullback( 36 | state: /State.counter, 37 | event: /Event.counter 38 | ), 39 | Reducer(reduce: Self.innerReducer(state:event:)) 40 | ) 41 | } 42 | 43 | static var feedbacks: Feedback { 44 | .combine( 45 | SignIn.feedback.pullback( 46 | state: /State.signIn, 47 | event: /Event.signIn, 48 | dependency: \.signIn 49 | ) 50 | ) 51 | } 52 | 53 | private static func innerReducer(state: inout State, event: Event) { 54 | switch event { 55 | case .signIn(.didSignIn): 56 | state = .counter(Counter.State()) 57 | default: 58 | break 59 | } 60 | } 61 | 62 | enum State: Equatable { 63 | case signIn(SignIn.State) 64 | case counter(Counter.State) 65 | } 66 | 67 | enum Event { 68 | case signIn(SignIn.Event) 69 | case counter(Counter.Event) 70 | } 71 | 72 | struct Dependencies { 73 | var signIn: SignIn.Dependencies 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Example/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "iphone", 5 | "size" : "20x20", 6 | "scale" : "2x" 7 | }, 8 | { 9 | "idiom" : "iphone", 10 | "size" : "20x20", 11 | "scale" : "3x" 12 | }, 13 | { 14 | "idiom" : "iphone", 15 | "size" : "29x29", 16 | "scale" : "2x" 17 | }, 18 | { 19 | "idiom" : "iphone", 20 | "size" : "29x29", 21 | "scale" : "3x" 22 | }, 23 | { 24 | "idiom" : "iphone", 25 | "size" : "40x40", 26 | "scale" : "2x" 27 | }, 28 | { 29 | "idiom" : "iphone", 30 | "size" : "40x40", 31 | "scale" : "3x" 32 | }, 33 | { 34 | "idiom" : "iphone", 35 | "size" : "60x60", 36 | "scale" : "2x" 37 | }, 38 | { 39 | "idiom" : "iphone", 40 | "size" : "60x60", 41 | "scale" : "3x" 42 | }, 43 | { 44 | "idiom" : "ipad", 45 | "size" : "20x20", 46 | "scale" : "1x" 47 | }, 48 | { 49 | "idiom" : "ipad", 50 | "size" : "20x20", 51 | "scale" : "2x" 52 | }, 53 | { 54 | "idiom" : "ipad", 55 | "size" : "29x29", 56 | "scale" : "1x" 57 | }, 58 | { 59 | "idiom" : "ipad", 60 | "size" : "29x29", 61 | "scale" : "2x" 62 | }, 63 | { 64 | "idiom" : "ipad", 65 | "size" : "40x40", 66 | "scale" : "1x" 67 | }, 68 | { 69 | "idiom" : "ipad", 70 | "size" : "40x40", 71 | "scale" : "2x" 72 | }, 73 | { 74 | "idiom" : "ipad", 75 | "size" : "76x76", 76 | "scale" : "1x" 77 | }, 78 | { 79 | "idiom" : "ipad", 80 | "size" : "76x76", 81 | "scale" : "2x" 82 | }, 83 | { 84 | "idiom" : "ipad", 85 | "size" : "83.5x83.5", 86 | "scale" : "2x" 87 | }, 88 | { 89 | "idiom" : "ios-marketing", 90 | "size" : "1024x1024", 91 | "scale" : "1x" 92 | } 93 | ], 94 | "info" : { 95 | "version" : 1, 96 | "author" : "xcode" 97 | } 98 | } -------------------------------------------------------------------------------- /Example/Views/AsyncImage.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import Combine 3 | import CombineFeedback 4 | 5 | struct AsyncImage: View { 6 | private let image: SwiftUI.State 7 | private let source: AnyPublisher 8 | private let content: (UIImage) -> Content 9 | 10 | init( 11 | source: AnyPublisher, 12 | placeholder: UIImage, 13 | @ViewBuilder content: @escaping (UIImage) -> Content 14 | ) { 15 | self.source = source 16 | self.image = SwiftUI.State(initialValue: placeholder) 17 | self.content = content 18 | } 19 | 20 | var body: some View { 21 | return content(image.wrappedValue) 22 | .bind(source, to: image.projectedValue) 23 | } 24 | } 25 | 26 | extension View { 27 | func bind( 28 | _ publisher: P, 29 | to state: Binding 30 | ) -> some View where P.Failure == Never, P.Output == Value { 31 | return onReceive(publisher) { value in 32 | state.wrappedValue = value 33 | } 34 | } 35 | } 36 | 37 | class ImageFetcher { 38 | private let cache = NSCache() 39 | 40 | func image(for url: URL) -> AnyPublisher { 41 | return Deferred { () -> AnyPublisher in 42 | if let image = self.cache.object(forKey: url as NSURL) { 43 | return Result.Publisher(image) 44 | .eraseToAnyPublisher() 45 | } 46 | 47 | return URLSession.shared 48 | .dataTaskPublisher(for: url) 49 | .map { $0.data } 50 | .compactMap(UIImage.init(data:)) 51 | .receive(on: DispatchQueue.main) 52 | .handleEvents(receiveOutput: { image in 53 | self.cache.setObject(image, forKey: url as NSURL) 54 | }) 55 | .ignoreError() 56 | } 57 | .eraseToAnyPublisher() 58 | } 59 | } 60 | 61 | struct ImageFetcherKey: EnvironmentKey { 62 | static let defaultValue = ImageFetcher() 63 | } 64 | 65 | extension EnvironmentValues { 66 | var imageFetcher: ImageFetcher { 67 | get { 68 | return self[ImageFetcherKey.self] 69 | } 70 | set { 71 | self[ImageFetcherKey.self] = newValue 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Example/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | $(DEVELOPMENT_LANGUAGE) 7 | CFBundleExecutable 8 | $(EXECUTABLE_NAME) 9 | CFBundleIdentifier 10 | $(PRODUCT_BUNDLE_IDENTIFIER) 11 | CFBundleInfoDictionaryVersion 12 | 6.0 13 | CFBundleName 14 | $(PRODUCT_NAME) 15 | CFBundlePackageType 16 | $(PRODUCT_BUNDLE_PACKAGE_TYPE) 17 | CFBundleShortVersionString 18 | 1.0 19 | CFBundleVersion 20 | 1 21 | LSRequiresIPhoneOS 22 | 23 | UIApplicationSceneManifest 24 | 25 | UIApplicationSupportsMultipleScenes 26 | 27 | UISceneConfigurations 28 | 29 | UIWindowSceneSessionRoleApplication 30 | 31 | 32 | UILaunchStoryboardName 33 | LaunchScreen 34 | UISceneConfigurationName 35 | Default Configuration 36 | UISceneDelegateClassName 37 | $(PRODUCT_MODULE_NAME).SceneDelegate 38 | 39 | 40 | 41 | 42 | UILaunchStoryboardName 43 | LaunchScreen 44 | UIRequiredDeviceCapabilities 45 | 46 | armv7 47 | 48 | UISupportedInterfaceOrientations 49 | 50 | UIInterfaceOrientationPortrait 51 | UIInterfaceOrientationLandscapeLeft 52 | UIInterfaceOrientationLandscapeRight 53 | 54 | UISupportedInterfaceOrientations~ipad 55 | 56 | UIInterfaceOrientationPortrait 57 | UIInterfaceOrientationPortraitUpsideDown 58 | UIInterfaceOrientationLandscapeLeft 59 | UIInterfaceOrientationLandscapeRight 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/Store/Store.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import Foundation 3 | import CasePaths 4 | 5 | open class Store { 6 | private let box: StoreBoxBase 7 | 8 | public var state: State { 9 | box._current 10 | } 11 | 12 | var publisher: AnyPublisher { 13 | box.publisher 14 | } 15 | 16 | init(box: StoreBoxBase) { 17 | self.box = box 18 | } 19 | 20 | public init( 21 | initial: State, 22 | feedbacks: [Feedback], 23 | reducer: Reducer, 24 | dependency: Dependency 25 | ) { 26 | self.box = RootStoreBox( 27 | initial: initial, 28 | feedbacks: feedbacks, 29 | reducer: reducer, 30 | dependency: dependency 31 | ) 32 | } 33 | 34 | @MainActor func context( 35 | removeDuplicates isDuplicate: @escaping (State, State) -> Bool 36 | ) -> ViewContext { 37 | ViewContext(store: box, removeDuplicates: isDuplicate) 38 | } 39 | 40 | open func send(event: Event) { 41 | box.send(event: event) 42 | } 43 | 44 | public func scope( 45 | getValue: @escaping (State) -> S 46 | ) -> Store { 47 | Store( 48 | box: box.scoped(getValue: getValue, event: { $0 }) 49 | ) 50 | } 51 | 52 | public func scope( 53 | getValue: @escaping (State) -> S, 54 | event: @escaping (E) -> Event 55 | ) -> Store { 56 | Store( 57 | box: box.scoped(getValue: getValue, event: event) 58 | ) 59 | } 60 | 61 | public func scoped( 62 | to scope: WritableKeyPath, 63 | event: @escaping (E) -> Event 64 | ) -> Store { 65 | return Store(box: box.scoped(to: scope, event: event)) 66 | } 67 | } 68 | 69 | extension Array { 70 | func appending(_ element: Element) -> [Element] { 71 | var copy = self 72 | 73 | copy.append(element) 74 | 75 | return copy 76 | } 77 | } 78 | 79 | public extension Publisher where Self.Failure == Never { 80 | func assign( 81 | to keyPath: WritableKeyPath, weakly object: Root 82 | ) -> AnyCancellable { 83 | return self.sink { [weak object] output in 84 | object?[keyPath: keyPath] = output 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Example/SingleStoreExample/Movies/MoviesState.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CombineFeedback 3 | import Combine 4 | 5 | enum Movies { 6 | struct Dependencies { 7 | var movies: (Int) async throws -> Results 8 | var fetchMovies: (Int) -> AnyPublisher 9 | } 10 | 11 | struct State: Equatable { 12 | var batch: Results 13 | var movies: [Movie] 14 | var status: Status 15 | 16 | var nextPage: Int? { 17 | switch status { 18 | case .loading: 19 | return batch.page + 1 20 | case .failed: 21 | return nil 22 | case .idle: 23 | return nil 24 | } 25 | } 26 | 27 | var error: NSError? { 28 | switch status { 29 | case .failed(let error): 30 | return error 31 | default: 32 | return nil 33 | } 34 | } 35 | 36 | enum Status: Equatable { 37 | case idle 38 | case loading 39 | case failed(NSError) 40 | } 41 | } 42 | 43 | enum Event { 44 | case didLoad(Results) 45 | case didFail(NSError) 46 | case retry 47 | case fetchNext 48 | case didLike(Movie, index: Int) 49 | } 50 | 51 | static func reducer() -> Reducer { 52 | .init { state, event in 53 | switch event { 54 | case .didLoad(let batch): 55 | state.movies += batch.results 56 | state.status = .idle 57 | state.batch = batch 58 | case .didFail(let error): 59 | state.status = .failed(error) 60 | case .retry: 61 | state.status = .loading 62 | case .fetchNext: 63 | state.status = .loading 64 | case .didLike(_, let index): 65 | state.movies[index].isFavourite = !state.movies[index].isFavourite 66 | } 67 | } 68 | } 69 | 70 | static var feedback: Feedback { 71 | if #available(iOS 15.0, *) { 72 | return .lensing(state: \.nextPage) { page, dependency in 73 | do { 74 | return Event.didLoad(try await URLSession.shared.movies(page: page)) 75 | } catch { 76 | return Event.didFail(error as NSError) 77 | } 78 | } 79 | } else { 80 | return .lensing(state: { $0.nextPage }) { page, dependency in 81 | dependency.fetchMovies(page) 82 | .map(Event.didLoad) 83 | .replaceError(replace: Event.didFail) 84 | .receive(on: DispatchQueue.main) 85 | } 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Example/TrafficLight/TrafficLight.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineFeedback 3 | import SwiftUI 4 | 5 | extension TrafficLight { 6 | final class ViewModel: Store { 7 | init() { 8 | super.init( 9 | initial: .red, 10 | feedbacks: [ 11 | ViewModel.whenRed(), 12 | ViewModel.whenYellow(), 13 | ViewModel.whenGreen() 14 | ], 15 | reducer: TrafficLight.reducer(), 16 | dependency: () 17 | ) 18 | } 19 | 20 | private static func whenRed() -> Feedback { 21 | .middleware { state, _ -> AnyPublisher in 22 | guard case .red = state else { 23 | return Empty().eraseToAnyPublisher() 24 | } 25 | 26 | return Result.Publisher(Event.next) 27 | .delay(for: 1, scheduler: DispatchQueue.main) 28 | .eraseToAnyPublisher() 29 | } 30 | } 31 | 32 | private static func whenYellow() -> Feedback { 33 | .middleware { state, _ -> AnyPublisher in 34 | guard case .yellow = state else { 35 | return Empty().eraseToAnyPublisher() 36 | } 37 | 38 | return Result.Publisher(Event.next) 39 | .delay(for: 1, scheduler: DispatchQueue.main) 40 | .eraseToAnyPublisher() 41 | } 42 | } 43 | 44 | private static func whenGreen() -> Feedback { 45 | .middleware { state, _ -> AnyPublisher in 46 | guard case .green = state else { 47 | return Empty().eraseToAnyPublisher() 48 | } 49 | 50 | return Result.Publisher(Event.next) 51 | .delay(for: 1, scheduler: DispatchQueue.main) 52 | .eraseToAnyPublisher() 53 | } 54 | } 55 | 56 | private static func reduce(state: State, event: Event) -> State { 57 | switch state { 58 | case .red: 59 | return .yellow 60 | case .yellow: 61 | return .green 62 | case .green: 63 | return .red 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Example/SingleStoreExample/TrafficLight/TrafficLightState.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineFeedback 3 | import Foundation 4 | 5 | enum TrafficLight { 6 | enum State: Equatable { 7 | case red 8 | case yellow 9 | case green 10 | 11 | var isRed: Bool { 12 | switch self { 13 | case .red: 14 | return true 15 | default: 16 | return false 17 | } 18 | } 19 | 20 | var isYellow: Bool { 21 | switch self { 22 | case .yellow: 23 | return true 24 | default: 25 | return false 26 | } 27 | } 28 | 29 | var isGreen: Bool { 30 | switch self { 31 | case .green: 32 | return true 33 | default: 34 | return false 35 | } 36 | } 37 | } 38 | 39 | enum Event { 40 | case next 41 | } 42 | 43 | static func reducer() -> Reducer { 44 | .init { state, _ in 45 | switch state { 46 | case .red: 47 | state = .yellow 48 | case .yellow: 49 | state = .green 50 | case .green: 51 | state = .red 52 | } 53 | } 54 | } 55 | 56 | static var feedback: Feedback { 57 | return Feedback.combine(whenRed(), whenYellow(), whenGreen()) 58 | } 59 | 60 | private static func whenRed() -> Feedback { 61 | .middleware { state, _ -> AnyPublisher in 62 | guard case .red = state else { 63 | return Empty().eraseToAnyPublisher() 64 | } 65 | 66 | return Result.Publisher(Event.next) 67 | .delay(for: 1, scheduler: DispatchQueue.main) 68 | .eraseToAnyPublisher() 69 | } 70 | } 71 | 72 | private static func whenYellow() -> Feedback { 73 | .middleware { state, _ -> AnyPublisher in 74 | guard case .yellow = state else { 75 | return Empty().eraseToAnyPublisher() 76 | } 77 | 78 | return Result.Publisher(Event.next) 79 | .delay(for: 1, scheduler: DispatchQueue.main) 80 | .eraseToAnyPublisher() 81 | } 82 | } 83 | 84 | private static func whenGreen() -> Feedback { 85 | .middleware { state, _ -> AnyPublisher in 86 | guard case .green = state else { 87 | return Empty().eraseToAnyPublisher() 88 | } 89 | 90 | return Result.Publisher(Event.next) 91 | .delay(for: 1, scheduler: DispatchQueue.main) 92 | .eraseToAnyPublisher() 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/Reducer.swift: -------------------------------------------------------------------------------- 1 | import CasePaths 2 | 3 | public struct Reducer { 4 | public let reduce: (inout State, Event) -> Void 5 | 6 | public init(reduce: @escaping (inout State, Event) -> Void) { 7 | self.reduce = reduce 8 | } 9 | 10 | public func callAsFunction(_ state: inout State, _ event: Event) { 11 | self.reduce(&state, event) 12 | } 13 | 14 | public static func combine(_ reducers: Reducer...) -> Reducer { 15 | return .init { state, event in 16 | for reducer in reducers { 17 | reducer(&state, event) 18 | } 19 | } 20 | } 21 | 22 | public func pullback( 23 | state stateKeyPath: WritableKeyPath, 24 | event eventCasePath: CasePath 25 | ) -> Reducer { 26 | return .init { globalState, globalEvent in 27 | guard let localAction = eventCasePath.extract(from: globalEvent) else { 28 | return 29 | } 30 | self(&globalState[keyPath: stateKeyPath], localAction) 31 | } 32 | } 33 | 34 | /* 35 | enum AppState { 36 | case authenticated(AuthenticatedState) 37 | case nonAuth(NonOutState) 38 | } 39 | 40 | enum AppEvent { 41 | case authenticated(AuthenticatedEvent) 42 | case nonAuth(NonOutEvent) 43 | } 44 | */ 45 | public func pullback( 46 | state stateCasePath: CasePath, 47 | event eventCasePath: CasePath 48 | ) -> Reducer { 49 | .init { globalState, globalEvent in 50 | guard let localEvent = eventCasePath.extract(from: globalEvent) else { return } 51 | guard var localState = stateCasePath.extract(from: globalState) else { return } 52 | self.reduce(&localState, localEvent) 53 | globalState = stateCasePath.embed(localState) 54 | } 55 | } 56 | 57 | public func optional() -> Reducer { 58 | return .init { state, event in 59 | if state == nil { 60 | return 61 | } 62 | self.reduce(&state!, event) 63 | } 64 | } 65 | 66 | public func logging( 67 | printer: @escaping (String) -> Void = { print($0) } 68 | ) -> Reducer { 69 | return .init { state, event in 70 | self(&state, event) 71 | printer("Action: \(event)") 72 | printer("Value:") 73 | var dumpedNewValue = "" 74 | dump(state, to: &dumpedNewValue) 75 | printer(dumpedNewValue) 76 | printer("---") 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Example/SceneDelegate.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import UIKit 3 | import CombineFeedback 4 | 5 | class SceneDelegate: UIResponder, UIWindowSceneDelegate { 6 | var window: UIWindow? 7 | 8 | func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) { 9 | guard let windowScene = scene as? UIWindowScene else { 10 | return 11 | } 12 | let window = UIWindow(windowScene: windowScene) 13 | window.rootViewController = makeSingleStoreExample() 14 | self.window = window 15 | window.makeKeyAndVisible() 16 | } 17 | 18 | private func makeSingleStoreExample() -> UIViewController { 19 | return UIHostingController( 20 | rootView: SingleStoreExampleView( 21 | store: Store( 22 | initial: State(), 23 | feedbacks: [ 24 | moviesFeedback, 25 | signInFeedback, 26 | trafficLightFeedback, 27 | switchStoreFeedback 28 | ], 29 | reducer: appReducer, 30 | dependency: AppDependency() 31 | ) 32 | ) 33 | ) 34 | } 35 | 36 | private func makeMultiStoreExample() -> UIViewController { 37 | let tabbarController = UITabBarController() 38 | 39 | let counter = UIHostingController( 40 | rootView: NavigationView { 41 | CounterView(store: Counter.ViewModel()) 42 | } 43 | ) 44 | 45 | counter.tabBarItem = UITabBarItem( 46 | title: nil, 47 | image: UIImage(systemName: "eye"), 48 | selectedImage: UIImage(systemName: "eye.fill") 49 | ) 50 | 51 | let movies = UIHostingController( 52 | rootView: NavigationView { 53 | MoviesView(store: Movies.ViewModel()) 54 | .navigationBarTitle(Text("Movies")) 55 | } 56 | ) 57 | 58 | movies.tabBarItem = UITabBarItem( 59 | title: nil, 60 | image: UIImage(systemName: "film"), 61 | selectedImage: UIImage(systemName: "film.fill") 62 | ) 63 | 64 | let signIn = UIHostingController( 65 | rootView: NavigationView { 66 | SignInView(store: SignIn.ViewModel()) 67 | .navigationBarTitle(Text("Sign In")) 68 | } 69 | ) 70 | 71 | signIn.tabBarItem = UITabBarItem( 72 | title: nil, 73 | image: UIImage(systemName: "person"), 74 | selectedImage: UIImage(systemName: "person.fill") 75 | ) 76 | 77 | let trafficLight = UIHostingController( 78 | rootView: NavigationView { 79 | TrafficLightView(store: TrafficLight.ViewModel()) 80 | .navigationBarTitle(Text("Traffic Light")) 81 | } 82 | ) 83 | 84 | trafficLight.tabBarItem = UITabBarItem( 85 | title: nil, 86 | image: UIImage(systemName: "tortoise"), 87 | selectedImage: UIImage(systemName: "tortoise.fill") 88 | ) 89 | 90 | tabbarController.viewControllers = [counter, movies, signIn, trafficLight] 91 | 92 | return tabbarController 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /CombineFeedback.xcodeproj/xcshareddata/xcschemes/Example.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 30 | 31 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /Example/SingleStoreExample/State.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineFeedback 3 | import CasePaths 4 | import Foundation 5 | 6 | struct State { 7 | var counter = Counter.State() 8 | var switchExample = SwitchStoreExample.State.signIn(SignIn.State()) 9 | var favouriteMovies = FavouriteMovies.State() 10 | var signIn = SignIn.State() 11 | var traficLight = TrafficLight.State.red 12 | } 13 | 14 | enum Event { 15 | case switchExample(SwitchStoreExample.Event) 16 | case counter(Counter.Event) 17 | case favouriteMovies(FavouriteMovies.Event) 18 | case signIn(SignIn.Event) 19 | case trafficLight(TrafficLight.Event) 20 | } 21 | 22 | let countReducer: Reducer = Counter.reducer() 23 | .pullback( 24 | state: \.counter, 25 | event: /Event.counter 26 | ) 27 | 28 | let switchStoreReducer: Reducer = SwitchStoreExample.reducer 29 | .pullback( 30 | state: \.switchExample, 31 | event: /Event.switchExample 32 | ) 33 | 34 | let switchStoreFeedback: Feedback = SwitchStoreExample.feedbacks 35 | .pullback( 36 | state: \.switchExample, 37 | event: /Event.switchExample) { 38 | SwitchStoreExample.Dependencies(signIn: $0.signIn) 39 | } 40 | 41 | let favouriteMoviesReducer: Reducer = FavouriteMovies.reducer 42 | .pullback( 43 | state: \State.favouriteMovies, 44 | event: /Event.favouriteMovies 45 | ) 46 | 47 | let moviesFeedback = Movies.feedback 48 | .pullback( 49 | state: \FavouriteMovies.State.moviesState, 50 | event: /FavouriteMovies.Event.movies, 51 | dependency: { (globalDependency: AppDependency) -> Movies.Dependencies in 52 | globalDependency.movies 53 | } 54 | ) 55 | .pullback( 56 | state: \State.favouriteMovies, 57 | event: /Event.favouriteMovies, 58 | dependency: { $0 } 59 | ) 60 | 61 | let signInReducer: Reducer = SignIn.reducer().pullback( 62 | state: \.signIn, 63 | event: /Event.signIn 64 | ) 65 | 66 | let signInFeedback: Feedback = SignIn.feedback 67 | .pullback( 68 | state: \.signIn, 69 | event: /Event.signIn, 70 | dependency: \.signIn 71 | ) 72 | 73 | let traficLightReducer: Reducer = TrafficLight.reducer() 74 | .pullback( 75 | state: \.traficLight, 76 | event: /Event.trafficLight 77 | ) 78 | 79 | let trafficLightFeedback: Feedback = TrafficLight.feedback.pullback( 80 | state: \.traficLight, 81 | event: /Event.trafficLight, 82 | dependency: { _ in } 83 | ) 84 | 85 | let appReducer = Reducer.combine( 86 | countReducer, 87 | switchStoreReducer, 88 | favouriteMoviesReducer, 89 | signInReducer, 90 | traficLightReducer 91 | ) 92 | 93 | struct AppDependency { 94 | let urlSession = URLSession.shared 95 | let api = GithubAPI() 96 | 97 | var movies: Movies.Dependencies { 98 | .init( 99 | movies: urlSession.movies(page:), 100 | fetchMovies: urlSession.fetchMovies(page:) 101 | ) 102 | } 103 | 104 | var signIn: SignIn.Dependencies { 105 | .init( 106 | signIn: api.signIn, 107 | usernameAvailable: api.usernameAvailable(username:) 108 | ) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/SwiftUI/SwitchStoreView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import CasePaths 3 | 4 | /// A view that can switch over a store of enum state and handle each case. 5 | /// 6 | /// An application may model parts of its state with enums. For example, app state may differ if a 7 | /// user is logged-in or not: 8 | /// 9 | /// ```swift 10 | /// enum AppState { 11 | /// case loggedIn(LoggedInState) 12 | /// case loggedOut(LoggedOutState) 13 | /// } 14 | /// ``` 15 | /// Gives compile time guaranties that all cases of the enum State can be handled 16 | ///```swift 17 | /// SwitchStoreView(store: store) { state in 18 | /// switch state { 19 | /// case .loggedIn: 20 | /// CaseLetStoreView(state: /AppState.loggedIn, action: Event.loggedIn) { store in 21 | /// LoggedInView(store: store) 22 | /// } 23 | /// case .loggedOut: 24 | /// CaseLetStoreView(state: /AppState.loggedOut, action: AppState.loggedOut) { store in 25 | /// SignInView(store: store) 26 | /// } 27 | /// } 28 | /// } 29 | public struct SwitchStoreView: View where Content: View { 30 | private let store: Store 31 | private let content: (State) -> Content 32 | private let removeDuplicates: (State, State) -> Bool 33 | 34 | public init( 35 | store: Store, 36 | removeDuplicates: @escaping (State, State) -> Bool, 37 | @ViewBuilder content: @escaping (State) -> Content 38 | ) { 39 | self.store = store 40 | self.removeDuplicates = removeDuplicates 41 | self.content = content 42 | } 43 | 44 | public var body: some View { 45 | WithContextView(store: store, removeDuplicates: removeDuplicates) { context in 46 | self.content(context[dynamicMember: \State.self]) 47 | } 48 | .environmentObject(StoreObservableObject(store: self.store)) 49 | } 50 | } 51 | 52 | extension SwitchStoreView where State: Equatable { 53 | public init( 54 | store: Store, 55 | @ViewBuilder content: @escaping (State) -> Content 56 | ) { 57 | self.init(store: store, removeDuplicates: ==, content: content) 58 | } 59 | } 60 | 61 | /// Implementation is taken and adapted from the TCA 62 | /// A convenient view helper that lets you match cases of the ``SwitchStoreView`` state 63 | public struct CaseLetStoreView: View { 64 | @EnvironmentObject private var store: StoreObservableObject 65 | public let toLocalState: (GlobalState) -> LocalState? 66 | public let fromLocalEvent: (LocalEvent) -> GlobalEvent 67 | public let content: (Store) -> Content 68 | 69 | public init( 70 | state toLocalState: @escaping (GlobalState) -> LocalState?, 71 | action fromLocalEvent: @escaping (LocalEvent) -> GlobalEvent, 72 | @ViewBuilder then content: @escaping (Store) -> Content 73 | ) { 74 | self.toLocalState = toLocalState 75 | self.fromLocalEvent = fromLocalEvent 76 | self.content = content 77 | } 78 | 79 | public var body: some View { 80 | IfLetStoreView( 81 | store: self.store.wrappedValue 82 | .scope( 83 | getValue: toLocalState, 84 | event: fromLocalEvent 85 | ), 86 | then: self.content 87 | ) 88 | } 89 | } 90 | 91 | private class StoreObservableObject: ObservableObject { 92 | let wrappedValue: Store 93 | 94 | init(store: Store) { 95 | self.wrappedValue = store 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /Tests/CombineFeedbackTests/CombineFeedbackTests.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | @testable import CombineFeedback 3 | import XCTest 4 | 5 | class CombineFeedbackTests: XCTestCase { 6 | var disposable: Cancellable! 7 | 8 | func test_emits_initial() { 9 | let initial = "initial" 10 | var result = [String]() 11 | 12 | let system = Publishers.system( 13 | initial: initial, 14 | feedbacks: [], 15 | reduce: .init { state, event in 16 | state += event 17 | }, 18 | dependency: () 19 | ) 20 | 21 | disposable = system.sink { 22 | result.append($0) 23 | } 24 | 25 | XCTAssertEqual(result, ["initial"]) 26 | } 27 | 28 | func test_reducer_with_one_feedback_loop() { 29 | let feedback = Feedback.middleware { _, _ in 30 | Just("_a") 31 | } 32 | let system = Publishers.system( 33 | initial: "initial", 34 | feedbacks: [feedback], 35 | reduce: .init { state, event in 36 | state += event 37 | }, 38 | dependency: () 39 | ) 40 | 41 | var result: [String] = [] 42 | disposable = system.output(in: 0...3).collect() 43 | .sink { 44 | result = $0 45 | } 46 | 47 | let expected = [ 48 | "initial", 49 | "initial_a", 50 | "initial_a_a", 51 | "initial_a_a_a", 52 | ] 53 | XCTAssertEqual(result, expected) 54 | } 55 | 56 | func test_reduce_with_two_immediate_feedback_loops() { 57 | let feedback1 = Feedback.middleware { _, _ in 58 | Just("_a") 59 | } 60 | let feedback2 = Feedback.middleware { _, _ in 61 | Just("_b") 62 | } 63 | let system = Publishers.system( 64 | initial: "initial", 65 | feedbacks: [feedback1, feedback2], 66 | reduce: .init { state, event in 67 | state += event 68 | }, 69 | dependency: () 70 | ) 71 | var results: [String] = [] 72 | 73 | _ = system.output(in: 0...5).collect().sink { 74 | results = $0 75 | } 76 | 77 | let expected = [ 78 | "initial", 79 | "initial_a", 80 | "initial_a_b", 81 | "initial_a_b_a", 82 | "initial_a_b_a_b", 83 | "initial_a_b_a_b_a", 84 | ] 85 | 86 | XCTAssertEqual(results, expected) 87 | } 88 | 89 | func test_should_observe_signals_immediately() { 90 | let input = Feedback.input 91 | let system = Publishers.system( 92 | initial: "initial", 93 | feedbacks: [ 94 | input.feedback, 95 | ], 96 | reduce: .init { state, event in 97 | state += event 98 | }, 99 | dependency: () 100 | ) 101 | 102 | var results: [String] = [] 103 | 104 | let cancel = system.sink( 105 | receiveValue: { 106 | results.append($0) 107 | } 108 | ) 109 | 110 | XCTAssertEqual(["initial"], results) 111 | input.observer("_a") 112 | XCTAssertEqual(["initial", "initial_a"], results) 113 | } 114 | 115 | func test_cancelation() { 116 | let input = Feedback.input 117 | let system = Publishers.system( 118 | initial: "initial", 119 | feedbacks: [ 120 | input.feedback, 121 | ], 122 | reduce: .init { state, event in 123 | state += event 124 | }, 125 | dependency: () 126 | ) 127 | 128 | var results: [String] = [] 129 | let cancel = system.sink( 130 | receiveValue: { 131 | results.append($0) 132 | } 133 | ) 134 | 135 | XCTAssertEqual(["initial"], results) 136 | input.observer("_a") 137 | input.observer("_b") 138 | cancel.cancel() 139 | input.observer("_c") 140 | XCTAssertEqual(["initial", "initial_a", "initial_a_b"], results) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/Store/StoreBox.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CasePaths 3 | import SwiftUI 4 | 5 | internal class RootStoreBox: StoreBoxBase { 6 | private let subject: CurrentValueSubject 7 | 8 | private let inputObserver: (Event) -> Void 9 | private var bag = Set() 10 | 11 | override var _current: State { 12 | subject.value 13 | } 14 | 15 | override var publisher: AnyPublisher { 16 | subject.eraseToAnyPublisher() 17 | } 18 | 19 | public init( 20 | initial: State, 21 | feedbacks: [Feedback], 22 | reducer: Reducer, 23 | dependency: Dependency 24 | ) { 25 | let input = Feedback.input 26 | self.subject = CurrentValueSubject(initial) 27 | self.inputObserver = input.observer 28 | Publishers.FeedbackLoop( 29 | initial: initial, 30 | reduce: reducer, 31 | feedbacks: feedbacks 32 | .appending(input.feedback), 33 | dependency: dependency 34 | ) 35 | .sink(receiveValue: { [subject] state in 36 | subject.send(state) 37 | }) 38 | .store(in: &bag) 39 | } 40 | 41 | override func send(event: Event) { 42 | self.inputObserver(event) 43 | } 44 | 45 | override func scoped( 46 | getValue: @escaping (State) -> S, 47 | event: @escaping (E) -> Event 48 | ) -> StoreBoxBase { 49 | ScopedStoreBox( 50 | parent: self, 51 | getValue: getValue, 52 | event: event 53 | ) 54 | } 55 | } 56 | 57 | internal class ScopedStoreBox: StoreBoxBase { 58 | private let parent: StoreBoxBase 59 | private let getValue: (RootState) -> ScopedState 60 | private let eventTransform: (ScopedEvent) -> RootEvent 61 | 62 | override var _current: ScopedState { 63 | getValue(parent._current) 64 | } 65 | 66 | override var publisher: AnyPublisher { 67 | parent.publisher.map(getValue).eraseToAnyPublisher() 68 | } 69 | 70 | init( 71 | parent: StoreBoxBase, 72 | getValue: @escaping (RootState) -> ScopedState, 73 | event: @escaping (ScopedEvent) -> RootEvent 74 | ) { 75 | self.parent = parent 76 | self.getValue = getValue 77 | self.eventTransform = event 78 | } 79 | 80 | override func send(event: ScopedEvent) { 81 | parent.send(event: eventTransform(event)) 82 | } 83 | 84 | override func scoped( 85 | getValue: @escaping (ScopedState) -> S, 86 | event: @escaping (E) -> ScopedEvent 87 | ) -> StoreBoxBase { 88 | ScopedStoreBox( 89 | parent: self.parent) { rootState in 90 | getValue(self.getValue(rootState)) 91 | } event: { e in 92 | self.eventTransform(event(e)) 93 | } 94 | } 95 | } 96 | 97 | internal class StoreBoxBase { 98 | /// Loop Internal SPI 99 | var _current: State { subclassMustImplement() } 100 | 101 | var publisher: AnyPublisher { subclassMustImplement() } 102 | 103 | func send(event: Event) { 104 | subclassMustImplement() 105 | } 106 | 107 | final func scoped( 108 | to scope: WritableKeyPath, 109 | event: @escaping (E) -> Event 110 | ) -> StoreBoxBase { 111 | self.scoped( 112 | getValue: { state in 113 | return state[keyPath: scope] 114 | }, 115 | event: event 116 | ) 117 | } 118 | 119 | func scoped( 120 | getValue: @escaping (State) -> S, 121 | event: @escaping (E) -> Event 122 | ) -> StoreBoxBase { 123 | subclassMustImplement() 124 | } 125 | } 126 | 127 | @inline(never) 128 | private func subclassMustImplement(function: StaticString = #function) -> Never { 129 | fatalError("Subclass must implement `\(function)`.") 130 | } 131 | -------------------------------------------------------------------------------- /Example/SingleStoreExample/Movies/FavouriteMovies.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import CombineFeedback 3 | import CasePaths 4 | import SwiftUI 5 | import Combine 6 | 7 | enum FavouriteMovies { 8 | struct RootView: View { 9 | @Environment(\.imageFetcher) 10 | private var fetcher: ImageFetcher 11 | 12 | var store: Store 13 | 14 | var body: some View { 15 | WithContextView(store: store) { context in 16 | ScrollView { 17 | if context.favouriteMovies.isEmpty { 18 | VStack { 19 | Button(action: context.action(for: Event.didChangeNavigation(true))) { 20 | HStack { 21 | Image(systemName: "plus") 22 | Text("Select movies") 23 | } 24 | } 25 | } 26 | } else { 27 | LazyVGrid( 28 | columns: Array( 29 | repeating: GridItem( 30 | .adaptive(minimum: 200, maximum: 400), 31 | spacing: 8, 32 | alignment: .leading 33 | ), 34 | count: 3 35 | ), 36 | alignment: .leading, 37 | spacing: 8 38 | ) { 39 | ForEach(context.favouriteMovies) { movie in 40 | gridItem(movie: movie) 41 | } 42 | } 43 | .padding(.horizontal) 44 | } 45 | } 46 | .navigate( 47 | using: context.binding(for: \.isNavigationActive, event: Event.didChangeNavigation) 48 | ) { 49 | MoviesView(store: store.scoped(to: \.moviesState, event: Event.movies)) 50 | } 51 | .navigationBarItems( 52 | leading: EmptyView(), 53 | trailing: Button( 54 | action: context.action(for: Event.didChangeNavigation(true)), 55 | label: { 56 | Image(systemName: "plus") 57 | } 58 | ) 59 | ) 60 | } 61 | } 62 | 63 | func gridItem(movie: Movie) -> some View { 64 | AsyncImage( 65 | source: movie.posterURL.map(fetcher.image) 66 | .default(to: Empty().eraseToAnyPublisher()), 67 | placeholder: UIImage(systemName: "film")! 68 | ) { image in 69 | Image(uiImage: image) 70 | .resizable() 71 | .frame(width: 100) 72 | .aspectRatio(0.7, contentMode: .fill) 73 | } 74 | } 75 | } 76 | 77 | struct State: Equatable { 78 | var favouriteMovies: [Movie] = [] 79 | var isNavigationActive: Bool = false 80 | var moviesState = Movies.State(batch: .empty(), movies: [], status: .loading) 81 | } 82 | 83 | enum Event { 84 | case movies(Movies.Event) 85 | case didChangeNavigation(Bool) 86 | } 87 | 88 | static var reducer: Reducer { 89 | Reducer.combine( 90 | .init { state, event in 91 | switch event { 92 | case let .movies(.didLike(movie, _)): 93 | if let index = state.favouriteMovies.firstIndex(where: { $0.id == movie.id }) { 94 | state.favouriteMovies.remove(at: index) 95 | } else { 96 | state.favouriteMovies.append(movie) 97 | } 98 | case let .didChangeNavigation(isActive): 99 | state.isNavigationActive = isActive 100 | default: 101 | break 102 | } 103 | }, 104 | Movies.reducer() 105 | .pullback( 106 | state: \State.moviesState, 107 | event: /Event.movies 108 | ) 109 | ) 110 | } 111 | } 112 | 113 | extension View { 114 | func navigate( 115 | using binding: Binding, 116 | @ViewBuilder destination: () -> Destination 117 | ) -> some View { 118 | background(NavigationLink(isActive: binding, destination: destination, label: EmptyView.init)) 119 | } 120 | 121 | func background(@ViewBuilder _ builder: () -> Content) -> some View { 122 | background(builder()) 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Example/SingleStoreExample/Signin/SigninState.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineFeedback 3 | import Foundation 4 | 5 | enum SignIn { 6 | struct Dependencies { 7 | var signIn: ( 8 | _ userName: String, 9 | _ email: String, 10 | _ password: String 11 | ) -> AnyPublisher 12 | 13 | var usernameAvailable: ( 14 | _ username: String 15 | ) -> AnyPublisher 16 | } 17 | 18 | struct State: Equatable { 19 | var userName = "" 20 | var email = "" 21 | var password = "" 22 | var repeatPassword = "" 23 | var termsAccepted = false 24 | var status = Status.idle 25 | var showSignedInAlert = false 26 | fileprivate(set) var isAvailable = false 27 | 28 | var canSubmit: Bool { 29 | return isAvailable 30 | && !userName.isEmpty 31 | && !email.isEmpty 32 | && !password.isEmpty 33 | && !repeatPassword.isEmpty 34 | && password == repeatPassword 35 | && termsAccepted 36 | } 37 | 38 | enum Status: Equatable { 39 | case checkingUserName 40 | case idle 41 | case submitting 42 | case signedIn 43 | 44 | var isCheckingUserName: Bool { 45 | switch self { 46 | case .checkingUserName: 47 | return true 48 | default: 49 | return false 50 | } 51 | } 52 | 53 | var isSubmitting: Bool { 54 | switch self { 55 | case .submitting: 56 | return true 57 | default: 58 | return false 59 | } 60 | } 61 | 62 | var isSignedIn: Bool { 63 | switch self { 64 | case .signedIn: 65 | return true 66 | default: 67 | return false 68 | } 69 | } 70 | } 71 | } 72 | 73 | enum Event { 74 | case isAvailable(Bool) 75 | case didSignIn(Bool) 76 | case emailDidChange(String) 77 | case passwordDidChange(String) 78 | case didChangeUserName(String) 79 | case repeatPasswordDidChange(String) 80 | case termsDidChange(Bool) 81 | case signIn 82 | case dismissAlertTap 83 | } 84 | 85 | static func reducer() -> Reducer { 86 | return .init { state, event in 87 | switch event { 88 | case .didChangeUserName(let userName): 89 | state.userName = userName 90 | state.status = userName.isEmpty ? .idle : .checkingUserName 91 | case .emailDidChange(let email): 92 | state.email = email 93 | case .passwordDidChange(let password): 94 | state.password = password 95 | case .repeatPasswordDidChange(let repeatPassword): 96 | state.repeatPassword = repeatPassword 97 | case .termsDidChange(let termsAccepted): 98 | state.termsAccepted = termsAccepted 99 | case .isAvailable(let isAvailable): 100 | state.isAvailable = isAvailable 101 | state.status = .idle 102 | case .signIn: 103 | state.status = .submitting 104 | state.showSignedInAlert = true 105 | case .didSignIn: 106 | state.status = .idle 107 | case .dismissAlertTap: 108 | state.showSignedInAlert = false 109 | } 110 | } 111 | } 112 | 113 | static var feedback: Feedback { 114 | return Feedback.combine( 115 | whenChangingUserName(), 116 | whenSubmitting() 117 | ) 118 | } 119 | 120 | static func whenChangingUserName() -> Feedback { 121 | return Feedback.custom { state, consumer, dependency in 122 | state 123 | .map { 124 | $0.0.userName 125 | } 126 | .filter { $0.isEmpty == false } 127 | .removeDuplicates() 128 | .debounce( 129 | for: 0.5, 130 | scheduler: DispatchQueue.main 131 | ) 132 | .flatMapLatest { userName in 133 | dependency.usernameAvailable(userName) 134 | .map(Event.isAvailable) 135 | } 136 | .enqueue(to: consumer) 137 | } 138 | } 139 | 140 | static func whenSubmitting() -> Feedback { 141 | return .middleware { (state: State, dependency: Dependencies) -> AnyPublisher in 142 | guard state.status.isSubmitting else { 143 | return Empty().eraseToAnyPublisher() 144 | } 145 | 146 | return dependency 147 | .signIn(state.userName, state.email, state.password) 148 | .map(Event.didSignIn) 149 | .eraseToAnyPublisher() 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ##### 2 | # OS X temporary files that should never be committed 3 | # 4 | # c.f. http://www.westwind.com/reference/os-x/invisibles.html 5 | 6 | .DS_Store 7 | 8 | # c.f. http://www.westwind.com/reference/os-x/invisibles.html 9 | 10 | .Trashes 11 | 12 | # c.f. http://www.westwind.com/reference/os-x/invisibles.html 13 | 14 | *.swp 15 | 16 | # *.lock - this is used and abused by many editors for many different things. 17 | # For the main ones I use (e.g. Eclipse), it should be excluded 18 | # from source-control, but YMMV 19 | 20 | *.lock 21 | !Podfile.lock 22 | !Pods/Manifest.lock 23 | 24 | Pods/ 25 | 26 | # 27 | # profile - REMOVED temporarily (on double-checking, this seems incorrect; I can't find it in OS X docs?) 28 | #profile 29 | 30 | 31 | #### 32 | # Xcode temporary files that should never be committed 33 | # 34 | # NB: NIB/XIB files still exist even on Storyboard projects, so we want this... 35 | 36 | *~.nib 37 | 38 | 39 | #### 40 | # Xcode build files - 41 | # 42 | # NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "DerivedData" 43 | 44 | DerivedData/ 45 | 46 | # NB: slash on the end, so we only remove the FOLDER, not any files that were badly named "build" 47 | 48 | build/ 49 | 50 | #### 51 | # Swift Package Manager build output directory 52 | .build 53 | # Swift Package Manager Xcode SPM integration 54 | .swiftpm 55 | 56 | ##### 57 | # Xcode private settings (window sizes, bookmarks, breakpoints, custom executables, smart groups) 58 | # 59 | # This is complicated: 60 | # 61 | # SOMETIMES you need to put this file in version control. 62 | # Apple designed it poorly - if you use "custom executables", they are 63 | # saved in this file. 64 | # 99% of projects do NOT use those, so they do NOT want to version control this file. 65 | # ..but if you're in the 1%, comment out the line "*.pbxuser" 66 | 67 | # .pbxuser: http://lists.apple.com/archives/xcode-users/2004/Jan/msg00193.html 68 | 69 | *.pbxuser 70 | 71 | # .mode1v3: http://lists.apple.com/archives/xcode-users/2007/Oct/msg00465.html 72 | 73 | *.mode1v3 74 | 75 | # .mode2v3: http://lists.apple.com/archives/xcode-users/2007/Oct/msg00465.html 76 | 77 | *.mode2v3 78 | 79 | # .perspectivev3: http://stackoverflow.com/questions/5223297/xcode-projects-what-is-a-perspectivev3-file 80 | 81 | *.perspectivev3 82 | 83 | # NB: also, whitelist the default ones, some projects need to use these 84 | !default.pbxuser 85 | !default.mode1v3 86 | !default.mode2v3 87 | !default.perspectivev3 88 | 89 | 90 | #### 91 | # Xcode 4 - semi-personal settings 92 | # 93 | # 94 | # OPTION 1: --------------------------------- 95 | # throw away ALL personal settings (including custom schemes! 96 | # - unless they are "shared") 97 | # 98 | # NB: this is exclusive with OPTION 2 below 99 | xcuserdata 100 | 101 | # OPTION 2: --------------------------------- 102 | # get rid of ALL personal settings, but KEEP SOME OF THEM 103 | # - NB: you must manually uncomment the bits you want to keep 104 | # 105 | # NB: this is exclusive with OPTION 1 above 106 | # 107 | #xcuserdata/**/* 108 | 109 | # (requires option 2 above): Personal Schemes 110 | # 111 | #!xcuserdata/**/xcschemes/* 112 | 113 | #### 114 | # XCode 4 workspaces - more detailed 115 | # 116 | # Workspaces are important! They are a core feature of Xcode - don't exclude them :) 117 | # 118 | # Workspace layout is quite spammy. For reference: 119 | # 120 | # /(root)/ 121 | # /(project-name).xcodeproj/ 122 | # project.pbxproj 123 | # /project.xcworkspace/ 124 | # contents.xcworkspacedata 125 | # /xcuserdata/ 126 | # /(your name)/xcuserdatad/ 127 | # UserInterfaceState.xcuserstate 128 | # /xcsshareddata/ 129 | # /xcschemes/ 130 | # (shared scheme name).xcscheme 131 | # /xcuserdata/ 132 | # /(your name)/xcuserdatad/ 133 | # (private scheme).xcscheme 134 | # xcschememanagement.plist 135 | # 136 | # 137 | 138 | #### 139 | # Xcode 4 - Deprecated classes 140 | # 141 | # Allegedly, if you manually "deprecate" your classes, they get moved here. 142 | # 143 | # We're using source-control, so this is a "feature" that we do not want! 144 | 145 | *.moved-aside 146 | 147 | #### 148 | # UNKNOWN: recommended by others, but I can't discover what these files are 149 | # 150 | # ...none. Everything is now explained. 151 | 152 | #### 153 | # 154 | # Pods and Gems 155 | # 156 | .bundle/ 157 | !Gemfile.lock 158 | 159 | # Builds 160 | *.ipa 161 | 162 | # ssl certificates 163 | *.crt 164 | # AppCode config files 165 | .idea/ 166 | 167 | Iconr\n 168 | Babylon/dist/ 169 | dist.zip 170 | Babylon.xcworkspace 171 | Babylon.xcworkspace/ 172 | !Babylon.xcworkspace/xcshareddata/ 173 | 174 | # Orig files skipped 175 | *.orig 176 | 177 | Carthage/Checkouts/* 178 | SDK/Carthage 179 | .idea/babylon-ios.iml 180 | .idea/runConfigurations/Babylon_STAGING1.xml 181 | 182 | # Fastlane 183 | fastlane/README.md 184 | fastlane/report.xml 185 | fastlane/test_output/ 186 | .vendor/ 187 | output/ 188 | 189 | #CodeClimate 190 | cc-test-reporter 191 | cobertura.xml 192 | 193 | #VS Code 194 | .vscode/ 195 | 196 | # Snapshot Tests 197 | BabylonSnapshotTests/Snapshots/FailureDiffs/ 198 | 199 | # Ignore other git repositories 200 | ./Bento 201 | -------------------------------------------------------------------------------- /Example/MoviesExample/Movies.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineFeedback 3 | import Foundation 4 | import SwiftUI 5 | 6 | extension Movies { 7 | final class ViewModel: Store { 8 | let initial = Movies.State( 9 | batch: Results.empty(), 10 | movies: [], 11 | status: .loading 12 | ) 13 | var feedbacks: [Feedback] { 14 | if #available(iOS 15.0, *) { 15 | return [ 16 | ViewModel.whenLoadingIOS15() 17 | ] 18 | } else { 19 | return [ 20 | ViewModel.whenLoading() 21 | ] 22 | } 23 | } 24 | 25 | init() { 26 | super.init( 27 | initial: initial, 28 | feedbacks: [ViewModel.whenLoading()], 29 | reducer: Movies.reducer(), 30 | dependency: () 31 | ) 32 | } 33 | 34 | private static func whenLoading() -> Feedback { 35 | .lensing(state: { $0.nextPage }) { page, _ in 36 | URLSession.shared 37 | .fetchMovies(page: page) 38 | .map(Event.didLoad) 39 | .replaceError(replace: Event.didFail) 40 | .receive(on: DispatchQueue.main) 41 | } 42 | } 43 | 44 | @available(iOS 15.0, *) 45 | private static func whenLoadingIOS15() -> Feedback { 46 | .lensing(state: \.nextPage) { page, _ in 47 | do { 48 | return Event.didLoad(try await URLSession.shared.movies(page: page)) 49 | } catch { 50 | return Event.didFail(error as NSError) 51 | } 52 | } 53 | } 54 | } 55 | } 56 | 57 | struct MoviesView: View { 58 | typealias State = Movies.State 59 | typealias Event = Movies.Event 60 | let store: Store 61 | 62 | init(store: Store) { 63 | self.store = store 64 | logInit(of: self) 65 | } 66 | 67 | var body: some View { 68 | WithContextView(store: store) { context in 69 | ScrollView { 70 | LazyVStack { 71 | ForEach(Array(context.movies.enumerated()), id: \.element) { element in 72 | MovieCell(movie: element.element) 73 | .contentShape(Rectangle()) 74 | .onTapGesture { 75 | context.send(event: Event.didLike(element.element, index: element.offset)) 76 | } 77 | } 78 | if context.status == .loading { 79 | Spinner(style: .medium) 80 | } 81 | } 82 | .padding(.horizontal) 83 | } 84 | .navigationBarTitle("Pagination Example", displayMode: .inline) 85 | } 86 | } 87 | } 88 | 89 | struct MovieCell: View { 90 | @Environment(\.imageFetcher) var fetcher: ImageFetcher 91 | var movie: Movie 92 | 93 | private var poster: AnyPublisher { 94 | return movie.posterURL.map(fetcher.image) 95 | .default(to: Empty().eraseToAnyPublisher()) 96 | } 97 | 98 | var body: some View { 99 | return HStack { 100 | AsyncImage(source: poster, placeholder: UIImage(systemName: "film")!) { image in 101 | Image(uiImage: image) 102 | .resizable() 103 | .frame(width: 100) 104 | .aspectRatio(0.7, contentMode: .fill) 105 | } 106 | Text(movie.title).font(.title) 107 | Spacer() 108 | Button(action: {}) { 109 | Image(systemName: movie.isFavourite ? "star.fill" : "star") 110 | } 111 | } 112 | } 113 | } 114 | 115 | struct Results: Codable, Equatable { 116 | let page: Int 117 | let totalResults: Int 118 | let totalPages: Int 119 | let results: [Movie] 120 | 121 | static func empty() -> Results { 122 | return Results(page: 0, totalResults: 0, totalPages: 0, results: []) 123 | } 124 | 125 | enum CodingKeys: String, CodingKey { 126 | case page 127 | case totalResults = "total_results" 128 | case totalPages = "total_pages" 129 | case results 130 | } 131 | } 132 | 133 | struct Movie: Codable, Hashable, Identifiable { 134 | let id: Int 135 | let overview: String 136 | let title: String 137 | let posterPath: String? 138 | var isFavourite = false 139 | 140 | var posterURL: URL? { 141 | return posterPath 142 | .map { 143 | "https://image.tmdb.org/t/p/w154\($0)" 144 | } 145 | .flatMap(URL.init(string:)) 146 | } 147 | 148 | enum CodingKeys: String, CodingKey { 149 | case id 150 | case overview 151 | case title 152 | case posterPath = "poster_path" 153 | } 154 | } 155 | 156 | let correctAPIKey = "d4f0bdb3e246e2cb3555211e765c89e3" 157 | var shouldFail = false 158 | 159 | func switchFail() { 160 | shouldFail = !shouldFail 161 | } 162 | 163 | extension URLSession { 164 | func fetchMovies(page: Int) -> AnyPublisher { 165 | let url = URL(string: "https://api.themoviedb.org/3/discover/movie?api_key=\(shouldFail ? "" : correctAPIKey)&sort_by=popularity.desc&page=\(page)")! 166 | let request = URLRequest(url: url) 167 | 168 | return dataTaskPublisher(for: request) 169 | .map { $0.data } 170 | .decode(type: Results.self, decoder: JSONDecoder()) 171 | .mapError { (error) -> NSError in 172 | error as NSError 173 | } 174 | .eraseToAnyPublisher() 175 | } 176 | 177 | @available(iOS 15.0, *) 178 | func movies(page: Int) async throws -> Results { 179 | let url = URL(string: "https://api.themoviedb.org/3/discover/movie?api_key=\(shouldFail ? "" : correctAPIKey)&sort_by=popularity.desc&page=\(page)")! 180 | let request = URLRequest(url: url) 181 | let decoder = JSONDecoder() 182 | let (data, _) = try await self.data(for: request, delegate: nil) 183 | return try decoder.decode(Results.self, from: data) 184 | } 185 | } 186 | 187 | extension Optional { 188 | func `default`(to value: Wrapped) -> Wrapped { 189 | return self ?? value 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/Internal/Floodgate.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Combine 3 | 4 | final class Floodgate: FeedbackEventConsumer, Subscription where S.Input == State, S.Failure == Never { 5 | struct QueueState { 6 | var events: [(Event, Token)] = [] 7 | var isOuterLifetimeEnded = false 8 | var hasEvents: Bool { 9 | events.isEmpty == false && isOuterLifetimeEnded == false 10 | } 11 | } 12 | 13 | let stateDidChange = PassthroughSubject<(State, Event?), Never>() 14 | 15 | private let reducerLock = NSLock() 16 | private var state: State 17 | private var hasStarted = false 18 | private var cancelable: Cancellable? 19 | 20 | private let queue = Atomic(QueueState()) 21 | private let reducer: Reducer 22 | private let feedbacks: [Feedback] 23 | private let sink: S 24 | private let dependency: Dependency 25 | 26 | init( 27 | state: State, 28 | feedbacks: [Feedback], 29 | sink: S, 30 | reducer: Reducer, 31 | dependency: Dependency 32 | ) { 33 | self.state = state 34 | self.feedbacks = feedbacks 35 | self.sink = sink 36 | self.reducer = reducer 37 | self.dependency = dependency 38 | } 39 | 40 | func bootstrap() { 41 | reducerLock.lock() 42 | defer { reducerLock.unlock() } 43 | 44 | guard !hasStarted else { return } 45 | hasStarted = true 46 | self.cancelable = feedbacks.map { 47 | $0.events(stateDidChange.eraseToAnyPublisher(), self, dependency) 48 | } 49 | _ = self.sink.receive(state) 50 | stateDidChange.send((state, nil)) 51 | drainEvents() 52 | } 53 | 54 | func request(_ demand: Subscribers.Demand) {} 55 | 56 | func cancel() { 57 | stateDidChange.send(completion: .finished) 58 | cancelable?.cancel() 59 | queue.modify { 60 | $0.isOuterLifetimeEnded = true 61 | } 62 | } 63 | 64 | override func process(_ event: Event, for token: Token) { 65 | enqueue(event, for: token) 66 | 67 | if reducerLock.try() { 68 | repeat { 69 | drainEvents() 70 | reducerLock.unlock() 71 | } while queue.withValue({ $0.hasEvents }) && reducerLock.try() 72 | // ^^^ 73 | // Restart the event draining after we unlock the reducer lock, iff: 74 | // 75 | // 1. the queue still has unprocessed events; and 76 | // 2. no concurrent actor has taken the reducer lock, which implies no event draining would be started 77 | // unless we take active action. 78 | // 79 | // This eliminates a race condition in the following sequence of operations: 80 | // 81 | // | Thread A | Thread B | 82 | // |------------------------------------|------------------------------------| 83 | // | concurrent dequeue: no item | | 84 | // | | concurrent enqueue | 85 | // | | trylock lock: BUSY | 86 | // | unlock lock | | 87 | // | | | 88 | // | <<< The enqueued event is left unprocessed. >>> | 89 | // 90 | // The trylock-unlock duo has a synchronize-with relationship, which ensures that Thread A must see any 91 | // concurrent enqueue that *happens before* the trylock. 92 | } 93 | } 94 | 95 | override func dequeueAllEvents(for token: Token) { 96 | queue.modify { $0.events.removeAll(where: { _, t in t == token }) } 97 | } 98 | 99 | private func enqueue(_ event: Event, for token: Token) { 100 | queue.modify { state -> QueueState in 101 | state.events.append((event, token)) 102 | return state 103 | } 104 | } 105 | 106 | private func dequeue() -> Event? { 107 | queue.modify { 108 | guard !$0.isOuterLifetimeEnded, !$0.events.isEmpty else { 109 | return nil 110 | } 111 | return $0.events.removeFirst().0 112 | } 113 | } 114 | 115 | private func drainEvents() { 116 | // Drain any recursively produced events. 117 | while let next = dequeue() { 118 | consume(next) 119 | } 120 | } 121 | 122 | private func consume(_ event: Event) { 123 | reducer(&state, event) 124 | _ = sink.receive(state) 125 | stateDidChange.send((state, event)) 126 | } 127 | } 128 | 129 | public extension Publisher where Failure == Never { 130 | func enqueue(to consumer: FeedbackEventConsumer) -> Publishers.Enqueue { 131 | return Publishers.Enqueue(upstream: self, consumer: consumer) 132 | } 133 | } 134 | 135 | public extension Publishers { 136 | struct Enqueue: Publisher where Upstream.Failure == Never { 137 | public typealias Output = Never 138 | public typealias Failure = Never 139 | private let upstream: Upstream 140 | private let consumer: FeedbackEventConsumer 141 | 142 | init(upstream: Upstream, consumer: FeedbackEventConsumer) { 143 | self.upstream = upstream 144 | self.consumer = consumer 145 | } 146 | 147 | public func receive(subscriber: S) where S: Subscriber, Failure == S.Failure, Output == S.Input { 148 | let token = Token() 149 | self.upstream.handleEvents( 150 | receiveOutput: { value in 151 | self.consumer.process(value, for: token) 152 | }, 153 | receiveCancel: { 154 | self.consumer.dequeueAllEvents(for: token) 155 | } 156 | ) 157 | .flatMap { _ -> Empty in 158 | Empty() 159 | } 160 | .receive(subscriber: subscriber) 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /Example/SignIn/SignIn.swift: -------------------------------------------------------------------------------- 1 | import Combine 2 | import CombineFeedback 3 | import SwiftUI 4 | 5 | extension SignIn { 6 | final class ViewModel: Store { 7 | init(initial: State = State()) { 8 | super.init( 9 | initial: initial, 10 | feedbacks: [ 11 | ViewModel.whenChangingUserName(api: GithubAPI()), 12 | ViewModel.whenSubmitting(api: GithubAPI()) 13 | ], 14 | reducer: SignIn.reducer(), 15 | dependency: () 16 | ) 17 | } 18 | 19 | static func whenChangingUserName(api: GithubAPI) -> Feedback { 20 | return Feedback.custom { state, consumer, _ in 21 | state 22 | .map { 23 | $0.0.userName 24 | } 25 | .filter { $0.isEmpty == false } 26 | .removeDuplicates() 27 | .debounce( 28 | for: 0.5, 29 | scheduler: DispatchQueue.main 30 | ) 31 | .flatMapLatest { userName in 32 | api.usernameAvailable(username: userName) 33 | .map(Event.isAvailable) 34 | .enqueue(to: consumer) 35 | } 36 | } 37 | } 38 | 39 | static func whenSubmitting(api: GithubAPI) -> Feedback { 40 | return .middleware { (state, _) -> AnyPublisher in 41 | guard state.status.isSubmitting else { 42 | return Empty().eraseToAnyPublisher() 43 | } 44 | 45 | return api 46 | .signIn(username: state.userName, email: state.email, password: state.password) 47 | .map(Event.didSignIn) 48 | .eraseToAnyPublisher() 49 | } 50 | } 51 | } 52 | } 53 | 54 | struct SignInView: View { 55 | typealias State = SignIn.State 56 | typealias Event = SignIn.Event 57 | 58 | let store: Store 59 | 60 | init(store: Store) { 61 | self.store = store 62 | logInit(of: self) 63 | } 64 | 65 | var body: some View { 66 | WithContextView(store: store) { context in 67 | Form { 68 | Section { 69 | HStack { 70 | TextField( 71 | "Username", 72 | text: context.binding(for: \.userName, event: Event.didChangeUserName) 73 | ) 74 | .textFieldStyle(RoundedBorderTextFieldStyle()) 75 | .textContentType(.username) 76 | if context.status.isCheckingUserName { 77 | Spinner(style: .medium) 78 | } else { 79 | Image(systemName: context.isAvailable ? "hand.thumbsup.fill" : "xmark.seal.fill") 80 | } 81 | } 82 | TextField( 83 | "Email", 84 | text: context.binding(for: \.email, event: Event.emailDidChange) 85 | ) 86 | .textFieldStyle(RoundedBorderTextFieldStyle()) 87 | .textContentType(.emailAddress) 88 | TextField( 89 | "Password", 90 | text: context.binding(for: \.password, event: Event.passwordDidChange) 91 | ) 92 | .textFieldStyle(RoundedBorderTextFieldStyle()) 93 | .textContentType(.newPassword) 94 | TextField( 95 | "Repeat Password", 96 | text: context.binding(for: \.repeatPassword, event: Event.repeatPasswordDidChange) 97 | ) 98 | .textFieldStyle(RoundedBorderTextFieldStyle()) 99 | .textContentType(.newPassword) 100 | } 101 | Section { 102 | Toggle(isOn: context.binding(for: \.termsAccepted, event: Event.termsDidChange)) { 103 | Text("Accept Terms and Conditions") 104 | } 105 | } 106 | Section { 107 | ZStack { 108 | HStack { 109 | Spacer() 110 | Button(action: context.action(for: .signIn)) { 111 | Text("Sign In") 112 | .multilineTextAlignment(.center) 113 | } 114 | .disabled(!context.canSubmit) 115 | Spacer() 116 | } 117 | Group { 118 | if context.status.isSubmitting { 119 | Spinner(style: .medium) 120 | } else { 121 | EmptyView() 122 | } 123 | } 124 | } 125 | } 126 | } 127 | .alert( 128 | isPresented: context.binding(for: \.showSignedInAlert, event: .dismissAlertTap), 129 | content: { 130 | Alert(title: Text("Signed In")) 131 | } 132 | ) 133 | } 134 | } 135 | } 136 | 137 | extension Publisher where Failure == Never { 138 | func promoteError(to: E.Type) -> Publishers.MapError { 139 | return mapError { _ -> E in } 140 | } 141 | } 142 | 143 | final class GithubAPI { 144 | func usernameAvailable(username: String) -> AnyPublisher { 145 | // Fake implementation 146 | return Result.Publisher(Int.random(in: 0 ... 100) % 2 == 0) 147 | .delay(for: 0.3, scheduler: DispatchQueue.main) 148 | .eraseToAnyPublisher() 149 | } 150 | 151 | func signIn(username: String, email: String, password: String) -> AnyPublisher { 152 | // Fake implementation 153 | return Result.Publisher(true) 154 | .delay(for: 0.3, scheduler: DispatchQueue.main) 155 | .eraseToAnyPublisher() 156 | } 157 | } 158 | 159 | extension String { 160 | var urlEscaped: String { 161 | return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? "" 162 | } 163 | } 164 | 165 | struct Switch: UIViewRepresentable { 166 | let isOn: Binding 167 | let animated = true 168 | 169 | func makeUIView(context: UIViewRepresentableContext) -> UISwitch { 170 | let view = UISwitch(frame: .zero) 171 | 172 | view.addTarget( 173 | context.coordinator, 174 | action: #selector(Target.action(_:)), 175 | for: .valueChanged 176 | ) 177 | 178 | return view 179 | } 180 | 181 | func updateUIView(_ uiView: UISwitch, context: UIViewRepresentableContext) { 182 | context.coordinator._action = { view in 183 | self.isOn.wrappedValue = view.isOn 184 | } 185 | uiView.setOn(isOn.wrappedValue, animated: animated) 186 | } 187 | 188 | func makeCoordinator() -> Target { 189 | return Target() 190 | } 191 | 192 | static func dismantleUIView(_ uiView: UISwitch, coordinator: Switch.Target) { 193 | uiView.removeTarget( 194 | coordinator, 195 | action: #selector(Target.action(_:)), 196 | for: .valueChanged 197 | ) 198 | } 199 | 200 | class Target: NSObject { 201 | override init() { 202 | super.init() 203 | } 204 | 205 | fileprivate var _action: ((UISwitch) -> Void)? 206 | 207 | @objc 208 | func action(_ sender: UISwitch) { 209 | _action?(sender) 210 | } 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CombineFeedback 2 | 3 | Unidirectional Reactive Architecture. This is a [Combine](https://developer.apple.com/documentation/combine) implemetation of [ReactiveFeedback](https://github.com/Babylonpartners/ReactiveFeedback) and [RxFeedback](https://github.com/kzaher/RxFeedback) 4 | 5 | ## Diagram 6 | 7 | ![](diagrams/ReactiveFeedback.jpg) 8 | 9 | ## Motivation 10 | 11 | Requirements for iOS apps have become huge. Our code has to manage a lot of state e.g. server responses, cached data, UI state, routing etc. Some may say that Reactive Programming can help us a lot but, in the wrong hands, it can do even more harm to your code base. 12 | 13 | The goal of this library is to provide a simple and intuitive approach to designing reactive state machines. 14 | 15 | ## Core Concepts 16 | 17 | ### State 18 | 19 | `State` is the single source of truth. It represents a state of your system and is usually a plain Swift type. Your state is immutable. The only way to transition from one `State` to another is to emit an `Event`. 20 | 21 | ### Event 22 | 23 | Represents all possible events that can happen in your system which can cause a transition to a new `State`. 24 | 25 | ### Reducer 26 | 27 | A Reducer is a pure function with a signature of `( inout State, Event) -> Void`. While `Event` represents an action that results in a `State` change, it's actually not what _causes_ the change. An `Event` is just that, a representation of the intention to transition from one state to another. What actually causes the `State` to change, the embodiment of the corresponding `Event`, is a Reducer. A Reducer is the only place where a `State` can be changed. 28 | 29 | ### Feedback 30 | 31 | While `State` represents where the system is at a given time, `Event` represents a state change, and a `Reducer` is the pure function that enacts the event causing the state to change, there is not as of yet any type to decide which event should take place given a particular current state. That's the job of the `Feedback`. It's essentially a "processing engine", listening to changes in the current `State` and emitting the corresponding next events to take place. Feedbacks don't directly mutate states. Instead, they only emit events which then cause states to change in reducers. 32 | 33 | To some extent it's like reactive [Middleware](https://redux.js.org/advanced/middleware) in [Redux](https://redux.js.org) 34 | 35 | ### Dependency 36 | 37 | Dependency is the type that holds all services that feature needs, such as API clients, analytics clients, etc. 38 | 39 | #### Store 40 | 41 | Store - is a base class responsible for initializing a UI state machine. It provides two ways to interact with it. 42 | 43 | - We can start a state machine by observing `var state: AnyPublisher`. 44 | - We can send input events into it via `public final func send(event: Event)`. 45 | 46 | This is useful if we want to mutate our state in response to user input. Let's consider a `Counter` example 47 | 48 | ```swift 49 | struct State { 50 | var count = 0 51 | } 52 | 53 | enum Event { 54 | case increment 55 | case decrement 56 | } 57 | ``` 58 | When we press **+** button we want the `State` of the system to be incremented by `1`. To do that somewhere in our UI we can do: 59 | 60 | ```swift 61 | Button(action: { 62 | store.send(event: .increment) 63 | }) { 64 | return Text("+").font(.largeTitle) 65 | } 66 | ``` 67 | 68 | Also, we can use the `send(event:)` method to initiate side effects. For example, imagine that we are building an infinite list, and we want to trigger the next batch load when a user reaches the end of the list. 69 | 70 | ```swift 71 | enum Event { 72 | case didLoad(Results) 73 | case didFail(Error) 74 | case fetchNext 75 | } 76 | 77 | struct State: Builder { 78 | var batch: Results 79 | var movies: [Movie] 80 | var status: Status 81 | } 82 | enum Status { 83 | case idle 84 | case loading 85 | case failed(Error) 86 | } 87 | 88 | struct MoviesView: View { 89 | typealias State = MoviesViewModel.State 90 | typealias Event = MoviesViewModel.Event 91 | let context: Context 92 | 93 | var body: some View { 94 | List { 95 | ForEach(context.movies.identified(by: \.id)) { movie in 96 | MovieCell(movie: movie).onAppear { 97 | // When we reach the end of the list 98 | // we send `fetchNext` event 99 | if self.context.movies.last == movie { 100 | self.context.send(event: .fetchNext) 101 | } 102 | } 103 | } 104 | } 105 | } 106 | } 107 | ``` 108 | When we send `.fetchNext` event, it goes to the `reducer` where we put our system into `.loading` state, which in response triggers effect in the `whenLoading` feedback, which is reacting to particular state changes 109 | 110 | ```swift 111 | static func reducer(state: inout State, event: Event) { 112 | switch event { 113 | case .didLoad(let batch): 114 | state.movies += batch.results 115 | state.status = .idle 116 | state.batch = batch 117 | case .didFail(let error): 118 | state.status = .failed(error) 119 | case .retry: 120 | state.status = .loading 121 | case .fetchNext: 122 | state.status = .loading 123 | } 124 | } 125 | 126 | static var feedback: Feedback { 127 | return Feedback(lensing: { $0.nextPage }) { page in 128 | URLSession.shared 129 | .fetchMovies(page: page) 130 | .map(Event.didLoad) 131 | .replaceError(replace: Event.didFail) 132 | .receive(on: DispatchQueue.main) 133 | } 134 | } 135 | ``` 136 | 137 | #### Composition 138 | 139 | Taking inspiration from [TCA](https://github.com/pointfreeco/swift-composable-architecture) `CombineFeedback` is build with a composition in mind. 140 | 141 | Meaning that we can compose smaller states into bigger states. For more details please see Example App. 142 | 143 | #### ViewContext 144 | 145 | `ViewContext` - is a rendering context that we can use to interact with UI and render information. Via `@dynamicMemberLookup` it has all of the properties of the `State` and several conveniences methods for more seamless integration with SwiftUI. (Credits to [@andersio](https://github.com/andersio)) 146 | 147 | ```swift 148 | struct State { 149 | var email = "" 150 | var password = "" 151 | } 152 | enum Event { 153 | case signIn 154 | } 155 | struct SignInView: View { 156 | private let store: Store 157 | 158 | init(store: Store) { 159 | self.store = store 160 | } 161 | 162 | var body: some View { 163 | WithContextView(store: store) { context in 164 | Form { 165 | Section { 166 | TextField(context.binding(for: \.email, event: Event.emailDidChange)) 167 | TextField(context.binding(for: \.password, event: Event.passwordDidCange)) 168 | Button(action: context.action(for: .signIn)) { 169 | Text("Sign In") 170 | } 171 | } 172 | } 173 | } 174 | } 175 | } 176 | ``` 177 | 178 | ### Example 179 | 180 | | Counter | Infinite List | SignIn Form | Traffic Light | 181 | | --- | --- | --- | --- | 182 | | | | | 183 | 184 | 185 | ### References 186 | 187 | [Automata theory](https://en.wikipedia.org/wiki/Automata_theory) 188 | [TCA](https://github.com/pointfreeco/swift-composable-architecture) 189 | [Finite-state machine](https://en.wikipedia.org/wiki/Finite-state_machine) 190 | [Mealy machine](https://en.wikipedia.org/wiki/Mealy_machine) 191 | -------------------------------------------------------------------------------- /Sources/CombineFeedback/Feedback.swift: -------------------------------------------------------------------------------- 1 | import CasePaths 2 | import Combine 3 | 4 | public struct Feedback { 5 | public let events: ( 6 | _ state: AnyPublisher<(State, Event?), Never>, 7 | _ output: FeedbackEventConsumer, 8 | _ dependency: Dependency 9 | ) -> Cancellable 10 | 11 | internal init(events: @escaping ( 12 | _ state: AnyPublisher<(State, Event?), Never>, 13 | _ output: FeedbackEventConsumer, 14 | _ dependency: Dependency 15 | ) -> Cancellable) { 16 | self.events = events 17 | } 18 | 19 | /// Creates a custom Feedback, with the complete liberty of defining the data flow. 20 | /// 21 | /// - important: While you may respond to state changes in whatever ways you prefer, you **must** enqueue produced 22 | /// events using the `Publisher.enqueue(to:)` operator to the `FeedbackEventConsumer` provided 23 | /// to you. Otherwise, the feedback loop will not be able to pick up and process your events. 24 | /// 25 | /// - parameters: 26 | /// - setup: The setup closure to construct a data flow producing events in respond to changes from `state`, 27 | /// and having them consumed by `output` using the `SignalProducer.enqueue(to:)` operator. 28 | public static func custom( 29 | _ setup: @escaping ( 30 | _ state: AnyPublisher<(State, Event?), Never>, 31 | _ output: FeedbackEventConsumer, 32 | _ dependency: Dependency 33 | ) -> P 34 | ) -> Feedback where P.Failure == Never, P.Output == Never { 35 | return Feedback { state, output, dependency -> Cancellable in 36 | setup(state, output, dependency).start() 37 | } 38 | } 39 | 40 | /// Creates a Feedback which re-evaluates the given effect every time the 41 | /// `Signal` derived from the latest state yields a new value. 42 | /// 43 | /// If the previous effect is still alive when a new one is about to start, 44 | /// the previous one would automatically be cancelled. 45 | /// 46 | /// - parameters: 47 | /// - transform: The transform which derives a `Signal` of values from the 48 | /// latest state. 49 | /// - effects: The side effect accepting transformed values produced by 50 | /// `transform` and yielding events that eventually affect 51 | /// the state. 52 | public static func compacting( 53 | state transform: @escaping (AnyPublisher) -> AnyPublisher, 54 | effects: @escaping (U, Dependency) -> Effect 55 | ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { 56 | custom { state, output, dependency in 57 | // NOTE: `observe(on:)` should be applied on the inner producers, so 58 | // that cancellation due to state changes would be able to 59 | // cancel outstanding events that have already been scheduled. 60 | transform(state.map(\.0).eraseToAnyPublisher()) 61 | .flatMapLatest { effects($0, dependency).enqueue(to: output) } 62 | } 63 | } 64 | 65 | public static func compacting( 66 | events transform: @escaping (AnyPublisher) -> AnyPublisher, 67 | effects: @escaping (U, Dependency) -> Effect 68 | ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { 69 | custom { state, output, dependency in 70 | // NOTE: `observe(on:)` should be applied on the inner producers, so 71 | // that cancellation due to state changes would be able to 72 | // cancel outstanding events that have already been scheduled. 73 | transform(state.map(\.1).compactMap { $0 }.eraseToAnyPublisher()) 74 | .flatMapLatest { effects($0, dependency).enqueue(to: output) } 75 | } 76 | } 77 | 78 | /// Creates a Feedback which re-evaluates the given effect every time the 79 | /// state changes, and the transform consequentially yields a new value 80 | /// distinct from the last yielded value. 81 | /// 82 | /// If the previous effect is still alive when a new one is about to start, 83 | /// the previous one would automatically be cancelled. 84 | /// 85 | /// - parameters: 86 | /// - transform: The transform to apply on the state. 87 | /// - effects: The side effect accepting transformed values produced by 88 | /// `transform` and yielding events that eventually affect 89 | /// the state. 90 | public static func skippingRepeated( 91 | state transform: @escaping (State) -> Control?, 92 | effects: @escaping (Control, Dependency) -> Effect 93 | ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { 94 | compacting(state: { 95 | $0.map(transform) 96 | .removeDuplicates() 97 | .eraseToAnyPublisher() 98 | }, effects: { control, dependency in 99 | control 100 | .map { effects($0, dependency) }? 101 | .eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() 102 | }) 103 | } 104 | 105 | @available(iOS 15.0, *) 106 | public static func skippingRepeated( 107 | state transform: @escaping (State) -> Control?, 108 | effect: @escaping (Control, Dependency) async -> Event 109 | ) -> Feedback { 110 | compacting(state: { 111 | $0.map(transform) 112 | .removeDuplicates() 113 | .eraseToAnyPublisher() 114 | }, effects: { control, dependency -> AnyPublisher in 115 | if let control = control { 116 | return TaskPublisher { 117 | await effect(control, dependency) 118 | }.eraseToAnyPublisher() 119 | } else { 120 | return Empty().eraseToAnyPublisher() 121 | } 122 | }) 123 | } 124 | 125 | /// Creates a Feedback which re-evaluates the given effect every time the 126 | /// state changes. 127 | /// 128 | /// If the previous effect is still alive when a new one is about to start, 129 | /// the previous one would automatically be cancelled. 130 | /// 131 | /// - parameters: 132 | /// - transform: The transform to apply on the state. 133 | /// - effects: The side effect accepting transformed values produced by 134 | /// `transform` and yielding events that eventually affect 135 | /// the state. 136 | public static func lensing( 137 | state transform: @escaping (State) -> Control?, 138 | effects: @escaping (Control, Dependency) -> Effect 139 | ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { 140 | compacting(state: { 141 | $0.map(transform).eraseToAnyPublisher() 142 | }, effects: { control, dependency in 143 | control.map { effects($0, dependency) }? 144 | .eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() 145 | }) 146 | } 147 | 148 | @available(iOS 15.0, *) 149 | public static func lensing( 150 | state transform: @escaping (State) -> Control?, 151 | effects: @escaping (Control, Dependency) async -> Event 152 | ) -> Feedback { 153 | compacting(state: { 154 | $0.map(transform).eraseToAnyPublisher() 155 | }, effects: { control, dependency -> AnyPublisher in 156 | if let control = control { 157 | return TaskPublisher { 158 | await effects(control, dependency) 159 | } 160 | .eraseToAnyPublisher() 161 | } else { 162 | return Empty().eraseToAnyPublisher() 163 | } 164 | }) 165 | } 166 | 167 | /// Creates a Feedback which re-evaluates the given effect every time the 168 | /// given predicate passes. 169 | /// 170 | /// If the previous effect is still alive when a new one is about to start, 171 | /// the previous one would automatically be cancelled. 172 | /// 173 | /// - parameters: 174 | /// - predicate: The predicate to apply on the state. 175 | /// - effects: The side effect accepting the state and yielding events 176 | /// that eventually affect the state. 177 | public static func predicate( 178 | predicate: @escaping (State) -> Bool, 179 | effects: @escaping (State, Dependency) -> Effect 180 | ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { 181 | return firstValueAfterNil({ state -> State? in 182 | predicate(state) ? state : nil 183 | }, effects: effects) 184 | } 185 | 186 | /// Creates a Feedback which re-evaluates the given effect every time the 187 | /// given predicate passes. 188 | /// 189 | /// If the previous effect is still alive when a new one is about to start, 190 | /// the previous one would automatically be cancelled. 191 | /// 192 | /// - Parameters: 193 | /// - predicate: The predicate to apply on the state. 194 | /// - effect: The side effect accepting the state and yielding events that eventually affect the state. 195 | @available(iOS 15.0, *) 196 | public static func predicate( 197 | predicate: @escaping (State) -> Bool, 198 | effect: @escaping (State, Dependency) async -> Event 199 | ) -> Feedback { 200 | return firstValueAfterNil { state -> State? in 201 | predicate(state) ? state : nil 202 | } effect: { state, dependency in 203 | await effect(state, dependency) 204 | } 205 | } 206 | 207 | /// Creates a Feedback which re-evaluates the given effect every time the 208 | /// state changes. 209 | /// 210 | /// If the previous effect is still alive when a new one is about to start, 211 | /// the previous one would automatically be cancelled. 212 | /// 213 | /// - parameters: 214 | /// - transform: The transform to apply on the state. 215 | /// - effects: The side effect accepting transformed values produced by 216 | /// `transform` and yielding events that eventually affect 217 | /// the state. 218 | public static func lensing( 219 | event transform: @escaping (Event) -> Payload?, 220 | effects: @escaping (Payload, Dependency) -> Effect 221 | ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { 222 | compacting(events: { 223 | $0.map(transform).eraseToAnyPublisher() 224 | }, effects: { payload, dependency in 225 | payload.map { effects($0, dependency) }? 226 | .eraseToAnyPublisher() ?? Empty().eraseToAnyPublisher() 227 | }) 228 | } 229 | 230 | /// Creates a Feedback which re-evaluates the given effect every time the 231 | /// state changes. 232 | /// 233 | /// If the previous effect is still alive when a new one is about to start, 234 | /// the previous one would automatically be cancelled. 235 | /// 236 | /// - parameters: 237 | /// - transform: The transform to apply on the state. 238 | /// - effects: The side effect accepting transformed values produced by 239 | /// `transform` and yielding events that eventually affect 240 | /// the state. 241 | @available(iOS 15.0, *) 242 | public static func lensing( 243 | event transform: @escaping (Event) -> Payload?, 244 | effect: @escaping (Payload, Dependency) async -> Event 245 | ) -> Feedback { 246 | compacting(events: { 247 | $0.map(transform).eraseToAnyPublisher() 248 | }, effects: { payload, dependency -> AnyPublisher in 249 | if let payload = payload { 250 | return TaskPublisher { 251 | await effect(payload, dependency) 252 | } 253 | .eraseToAnyPublisher() 254 | } else { 255 | return Empty().eraseToAnyPublisher() 256 | } 257 | }) 258 | } 259 | 260 | public static func firstValueAfterNil( 261 | _ transform: @escaping (State) -> Value?, 262 | effects: @escaping (Value, Dependency) -> Effect 263 | ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { 264 | return .compacting( 265 | state: { state -> AnyPublisher, Never> in 266 | state.scan((lastWasNil: true, output: NilEdgeTransition?.none)) { acum, state in 267 | var temp = acum 268 | let result = transform(state) 269 | temp.output = nil 270 | 271 | switch (temp.lastWasNil, result) { 272 | case (true, .none), (false, .some): 273 | return temp 274 | case let (true, .some(value)): 275 | temp.lastWasNil = false 276 | temp.output = .populated(value) 277 | case (false, .none): 278 | temp.lastWasNil = true 279 | temp.output = .cleared 280 | } 281 | return temp 282 | } 283 | .compactMap(\.output) 284 | .eraseToAnyPublisher() 285 | }, 286 | effects: { transition, dependency -> AnyPublisher in 287 | switch transition { 288 | case let .populated(value): 289 | return effects(value, dependency).eraseToAnyPublisher() 290 | case .cleared: 291 | return Empty().eraseToAnyPublisher() 292 | } 293 | } 294 | ) 295 | } 296 | 297 | @available(iOS 15.0, *) 298 | public static func firstValueAfterNil( 299 | _ transform: @escaping (State) -> Value?, 300 | effect: @escaping (Value, Dependency) async -> Event 301 | ) -> Feedback { 302 | .firstValueAfterNil(transform) { value, dependency in 303 | TaskPublisher { 304 | await effect(value, dependency) 305 | } 306 | } 307 | } 308 | 309 | /// Redux like Middleware signature Feedback factory method that lets you perform side effects when state changes 310 | /// 311 | /// If the previous effect is still alive when a new one is about to start, 312 | /// the previous one would automatically be cancelled. 313 | /// 314 | /// - parameters: 315 | /// - effects: The side effect accepting the state and yielding events 316 | /// that eventually affect the state. 317 | public static func middleware( 318 | _ effects: @escaping (State, Dependency) -> Effect 319 | ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { 320 | compacting(state: { $0 }, effects: effects) 321 | } 322 | 323 | /// Redux like Middleware signature Feedback factory method that lets you perform side effects when state changes 324 | /// 325 | /// If the previous effect is still alive when a new one is about to start, 326 | /// the previous one would automatically be cancelled. 327 | /// 328 | /// - parameters: 329 | /// - effects: The side effect accepting the state and yielding events 330 | /// that eventually affect the state. 331 | @available(iOS 15.0, *) 332 | public static func middleware( 333 | _ effect: @escaping (State, Dependency) async -> Event 334 | ) -> Feedback { 335 | compacting(state: { $0 }, effects: { state, dependency in 336 | TaskPublisher { 337 | await effect(state, dependency) 338 | } 339 | }) 340 | } 341 | 342 | /// Redux like Middleware signature Feedback factory method that lets you perform side effects when state changes, also knowing which event cased it 343 | /// 344 | /// If the previous effect is still alive when a new one is about to start, 345 | /// the previous one would automatically be cancelled. 346 | /// 347 | /// Important: State value is coming after reducer with an Event that caused the mutation 348 | /// 349 | /// - parameters: 350 | /// - effects: The side effect accepting the state and yielding events 351 | /// that eventually affect the state. 352 | public static func middleware( 353 | _ effects: @escaping (State, Event, Dependency) -> Effect 354 | ) -> Feedback where Effect.Output == Event, Effect.Failure == Never { 355 | custom { state, output, dependency in 356 | state.compactMap { s, e -> (State, Event)? in 357 | guard let e = e else { 358 | return nil 359 | } 360 | return (s, e) 361 | } 362 | .flatMapLatest { 363 | effects($0, $1, dependency) 364 | .enqueue(to: output) 365 | } 366 | } 367 | } 368 | 369 | /// Redux like Middleware signature Feedback factory method that lets you perform side effects when state changes, also knowing which event cased it 370 | /// 371 | /// If the previous effect is still alive when a new one is about to start, 372 | /// the previous one would automatically be cancelled. 373 | /// 374 | /// Important: State value is coming after reducer with an Event that caused the mutation 375 | /// 376 | /// - parameters: 377 | /// - effects: The side effect accepting the state and yielding events 378 | /// that eventually affect the state. 379 | @available(iOS 15.0, *) 380 | public static func middleware( 381 | _ effects: @escaping (State, Event, Dependency) async -> Event 382 | ) -> Feedback { 383 | custom { state, output, dependency in 384 | state.compactMap { s, e -> (State, Event)? in 385 | guard let e = e else { 386 | return nil 387 | } 388 | return (s, e) 389 | } 390 | .flatMapLatest { state, event in 391 | TaskPublisher { 392 | await effects(state, event, dependency) 393 | } 394 | .enqueue(to: output) 395 | } 396 | } 397 | } 398 | 399 | /// Redux like Middleware signature Feedback factory method that lets you perform side effects when state changes, also knowing which event cased it 400 | /// 401 | /// If the previous effect is still alive when a new one is about to start, 402 | /// the previous one would automatically be cancelled. 403 | /// 404 | /// Important: State value is coming after reducer with an Event that caused the mutation 405 | /// 406 | /// - parameters: 407 | /// - effects: The side effect accepting the state and yielding events 408 | /// that eventually affect the state. 409 | @available(iOS 15.0, *) 410 | public static func middleware( 411 | _ effect: @escaping (Event, Dependency) async -> Event 412 | ) -> Feedback { 413 | custom { state, output, dependency in 414 | state.compactMap { _, e -> Event? in 415 | guard let e = e else { 416 | return nil 417 | } 418 | return e 419 | } 420 | .flatMapLatest { event in 421 | TaskPublisher { 422 | await effect(event, dependency) 423 | } 424 | .enqueue(to: output) 425 | } 426 | } 427 | } 428 | } 429 | 430 | public extension Feedback { 431 | /// Transforms a Feedback that works on local state, event, and dependency into one that works on 432 | /// global state, action and dependency. It accomplishes this by providing 3 transformations to 433 | /// the method: 434 | /// 435 | /// * A key path that can get a piece of local state from the global state. 436 | /// * A case path that can extract/embed a local event into a global event. 437 | /// * A function that can transform the global dependency into a local dependency. 438 | func pullback( 439 | state stateKeyPath: KeyPath, 440 | event eventCasePath: CasePath, 441 | dependency toLocal: @escaping (GlobalDependency) -> Dependency 442 | ) -> Feedback { 443 | return Feedback(events: { state, consumer, dependency in 444 | let state = state.map { 445 | ($0[keyPath: stateKeyPath], $1.flatMap(eventCasePath.extract(from:))) 446 | }.eraseToAnyPublisher() 447 | return self.events( 448 | state, 449 | consumer.pullback(eventCasePath.embed), 450 | toLocal(dependency) 451 | ) 452 | }) 453 | } 454 | 455 | /// Transforms a Feedback that works on local state, event, and dependency into one that works on 456 | /// global state, action and dependency. It accomplishes this by providing 3 transformations to 457 | /// the method: 458 | /// 459 | /// An application may model parts of its state with enums. For example, app state may differ if a 460 | /// user is logged-in or not: 461 | /// 462 | /// ```swift 463 | /// enum AppState { 464 | /// case loggedIn(LoggedInState) 465 | /// case loggedOut(LoggedOutState) 466 | /// } 467 | /// ``` 468 | /// 469 | /// * A case path that can extract/embed a local state into a global state. 470 | /// * A case path that can extract/embed a local event into a global event. 471 | /// * A function that can transform the global dependency into a local dependency. 472 | func pullback( 473 | state stateCasePath: CasePath, 474 | event eventCasePath: CasePath, 475 | dependency toLocal: @escaping (GlobalDependency) -> Dependency 476 | ) -> Feedback { 477 | return Feedback(events: { state, consumer, dependency in 478 | let state: AnyPublisher<(State, Event?), Never> = state.compactMap { (stateAndEvent: (GlobalState, GlobalEvent?)) -> (State, Event?)? in 479 | guard let localState = stateCasePath.extract(from: stateAndEvent.0) else { 480 | return nil 481 | } 482 | return (localState, stateAndEvent.1.flatMap(eventCasePath.extract(from:))) 483 | }.eraseToAnyPublisher() 484 | return self.events( 485 | state, 486 | consumer.pullback(eventCasePath.embed), 487 | toLocal(dependency) 488 | ) 489 | }) 490 | } 491 | 492 | static func combine( 493 | _ feedbacks: Feedback...) -> Feedback 494 | { 495 | return Feedback { (state, consumer, dependency) -> Cancellable in 496 | feedbacks.map { (feedback) -> Cancellable in 497 | feedback.events(state, consumer, dependency) 498 | } 499 | } 500 | } 501 | 502 | static var input: (feedback: Feedback, observer: (Event) -> Void) { 503 | let subject = PassthroughSubject() 504 | let feedback = Feedback.custom { _, consumer, _ in 505 | subject.enqueue(to: consumer) 506 | } 507 | return (feedback, subject.send) 508 | } 509 | } 510 | 511 | public extension Feedback { 512 | func optional() -> Feedback { 513 | return Feedback { state, output, dependency in 514 | self.events( 515 | state.filter { stateAndEvent -> Bool in 516 | stateAndEvent.0 != nil 517 | } 518 | .map { ($0!, $1) } 519 | .eraseToAnyPublisher(), 520 | output, 521 | dependency 522 | ) 523 | } 524 | } 525 | } 526 | 527 | extension Array: Cancellable where Element == Cancellable { 528 | public func cancel() { 529 | for element in self { 530 | element.cancel() 531 | } 532 | } 533 | } 534 | 535 | @available(iOS 15.0, *) 536 | struct TaskPublisher: Publisher { 537 | typealias Failure = Never 538 | 539 | let work: () async -> Output 540 | 541 | init(work: @escaping () async -> Output) { 542 | self.work = work 543 | } 544 | 545 | func receive(subscriber: S) where S: Subscriber, Self.Failure == S.Failure, Self.Output == S.Input { 546 | let subscription = TaskSubscription(work: work, subscriber: subscriber) 547 | subscriber.receive(subscription: subscription) 548 | subscription.start() 549 | } 550 | 551 | final class TaskSubscription: Combine.Subscription where Downstream.Input == Output, Downstream.Failure == Never { 552 | private var handle: Task? 553 | private let work: () async -> Output 554 | private let subscriber: Downstream 555 | 556 | init(work: @escaping () async -> Output, subscriber: Downstream) { 557 | self.work = work 558 | self.subscriber = subscriber 559 | } 560 | 561 | func start() { 562 | self.handle = Task.init { [subscriber, work] in 563 | let result = await work() 564 | _ = subscriber.receive(result) 565 | subscriber.receive(completion: .finished) 566 | return result 567 | } 568 | } 569 | 570 | func request(_ demand: Subscribers.Demand) {} 571 | 572 | func cancel() { 573 | handle?.cancel() 574 | } 575 | } 576 | } 577 | 578 | private enum NilEdgeTransition { 579 | case populated(Value) 580 | case cleared 581 | } 582 | -------------------------------------------------------------------------------- /CombineFeedback.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 52; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 2510CDDF242BD9FF004A6422 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2510CDDE242BD9FF004A6422 /* Log.swift */; }; 11 | 2510CDE1242BED63004A6422 /* StoreExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2510CDE0242BED63004A6422 /* StoreExtensions.swift */; }; 12 | 251445E724086A400062EE04 /* TrafficLightView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251445E624086A400062EE04 /* TrafficLightView.swift */; }; 13 | 252BF08422BAE05700BC4265 /* SignIn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 252BF08322BAE05700BC4265 /* SignIn.swift */; }; 14 | 2541EA422716F459004F0CD5 /* FavouriteMovies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2541EA412716F459004F0CD5 /* FavouriteMovies.swift */; }; 15 | 254B4DBA2676650000653BB8 /* CombineSchedulers in Frameworks */ = {isa = PBXBuildFile; productRef = 254B4DB92676650000653BB8 /* CombineSchedulers */; }; 16 | 25AE0783271056C600FF03EC /* StoreBox.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AE0781271056C600FF03EC /* StoreBox.swift */; }; 17 | 25AE0784271056C600FF03EC /* Store.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AE0782271056C600FF03EC /* Store.swift */; }; 18 | 25AE078A271056D600FF03EC /* ViewContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AE0786271056D600FF03EC /* ViewContext.swift */; }; 19 | 25AE078B271056D600FF03EC /* SwitchStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AE0787271056D600FF03EC /* SwitchStoreView.swift */; }; 20 | 25AE078C271056D600FF03EC /* WithContextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AE0788271056D600FF03EC /* WithContextView.swift */; }; 21 | 25AE078D271056D600FF03EC /* IfLetStoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AE0789271056D600FF03EC /* IfLetStoreView.swift */; }; 22 | 25AE0790271066FD00FF03EC /* SwitchStoreExample.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25AE078F271066FD00FF03EC /* SwitchStoreExample.swift */; }; 23 | 25C57B2C22BC2C33007CB4D6 /* Activity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C57B2B22BC2C33007CB4D6 /* Activity.swift */; }; 24 | 25EBC08C23FD61B100719826 /* Reducer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD878239AC7D3004BE9CC /* Reducer.swift */; }; 25 | 25F23C2922CA984E00894863 /* TrafficLight.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25F23C2822CA984E00894863 /* TrafficLight.swift */; }; 26 | 5800FF9A22A89BE6005A860B /* CombineFeedback.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5800FF9022A89BE6005A860B /* CombineFeedback.framework */; }; 27 | 5800FF9F22A89BE6005A860B /* CombineFeedbackTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5800FF9E22A89BE6005A860B /* CombineFeedbackTests.swift */; }; 28 | 5800FFAE22A89C09005A860B /* Feedback.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5800FFAA22A89C08005A860B /* Feedback.swift */; }; 29 | 5800FFAF22A89C09005A860B /* FlatMapLatest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5800FFAB22A89C08005A860B /* FlatMapLatest.swift */; }; 30 | 5800FFB022A89C09005A860B /* System.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5800FFAC22A89C08005A860B /* System.swift */; }; 31 | 5800FFB922A8CDA5005A860B /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5800FFB822A8CDA5005A860B /* AppDelegate.swift */; }; 32 | 5800FFBB22A8CDA5005A860B /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5800FFBA22A8CDA5005A860B /* SceneDelegate.swift */; }; 33 | 5800FFBD22A8CDA5005A860B /* Counter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5800FFBC22A8CDA5005A860B /* Counter.swift */; }; 34 | 5800FFBF22A8CDA7005A860B /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5800FFBE22A8CDA7005A860B /* Assets.xcassets */; }; 35 | 5800FFC222A8CDA7005A860B /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 5800FFC122A8CDA7005A860B /* Preview Assets.xcassets */; }; 36 | 5800FFC522A8CDA7005A860B /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 5800FFC322A8CDA7005A860B /* LaunchScreen.storyboard */; }; 37 | 5800FFD022A8CDA7005A860B /* ExampleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5800FFCF22A8CDA7005A860B /* ExampleTests.swift */; }; 38 | 5800FFDB22A8ED0F005A860B /* CombineFeedback.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5800FF9022A89BE6005A860B /* CombineFeedback.framework */; }; 39 | 5800FFDC22A8ED0F005A860B /* CombineFeedback.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 5800FF9022A89BE6005A860B /* CombineFeedback.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 40 | 5822A2302434FEB400270514 /* CasePaths in Frameworks */ = {isa = PBXBuildFile; productRef = 5822A22F2434FEB400270514 /* CasePaths */; }; 41 | 583971C322ADBA9900139CC0 /* PublisherExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 583971C222ADBA9900139CC0 /* PublisherExtensions.swift */; }; 42 | 585CD869239A9D31004BE9CC /* State.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD868239A9D31004BE9CC /* State.swift */; }; 43 | 585CD86C239A9E34004BE9CC /* CounterState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD86B239A9E34004BE9CC /* CounterState.swift */; }; 44 | 585CD86F239A9E57004BE9CC /* MoviesState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD86E239A9E57004BE9CC /* MoviesState.swift */; }; 45 | 585CD872239A9EC9004BE9CC /* SigninState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD871239A9EC9004BE9CC /* SigninState.swift */; }; 46 | 585CD875239AA1E0004BE9CC /* TrafficLightState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD874239AA1E0004BE9CC /* TrafficLightState.swift */; }; 47 | 585CD877239AC19B004BE9CC /* SingleStoreExampleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 585CD876239AC19B004BE9CC /* SingleStoreExampleView.swift */; }; 48 | 58751A9223EC814600EEF398 /* FeedbackEventConsumer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58751A9123EC814500EEF398 /* FeedbackEventConsumer.swift */; }; 49 | 58751A9423EC819A00EEF398 /* Floodgate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58751A9323EC819A00EEF398 /* Floodgate.swift */; }; 50 | 58751A9623EC823C00EEF398 /* Atomic.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58751A9523EC823C00EEF398 /* Atomic.swift */; }; 51 | 58751A9823EC82D400EEF398 /* NSLockExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58751A9723EC82D400EEF398 /* NSLockExtensions.swift */; }; 52 | 58751A9A23ECBCA000EEF398 /* FeedbackLoop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58751A9923ECBCA000EEF398 /* FeedbackLoop.swift */; }; 53 | 58C6E5E022A9F027005A9685 /* Movies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6E5DF22A9F027005A9685 /* Movies.swift */; }; 54 | 58C6E5E722AB14DB005A9685 /* AsyncImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 58C6E5E622AB14DB005A9685 /* AsyncImage.swift */; }; 55 | /* End PBXBuildFile section */ 56 | 57 | /* Begin PBXContainerItemProxy section */ 58 | 5800FF9B22A89BE6005A860B /* PBXContainerItemProxy */ = { 59 | isa = PBXContainerItemProxy; 60 | containerPortal = 5800FF8722A89BE6005A860B /* Project object */; 61 | proxyType = 1; 62 | remoteGlobalIDString = 5800FF8F22A89BE6005A860B; 63 | remoteInfo = CombineFeedback; 64 | }; 65 | 5800FFCC22A8CDA7005A860B /* PBXContainerItemProxy */ = { 66 | isa = PBXContainerItemProxy; 67 | containerPortal = 5800FF8722A89BE6005A860B /* Project object */; 68 | proxyType = 1; 69 | remoteGlobalIDString = 5800FFB522A8CDA5005A860B; 70 | remoteInfo = Example; 71 | }; 72 | 5800FFDD22A8ED0F005A860B /* PBXContainerItemProxy */ = { 73 | isa = PBXContainerItemProxy; 74 | containerPortal = 5800FF8722A89BE6005A860B /* Project object */; 75 | proxyType = 1; 76 | remoteGlobalIDString = 5800FF8F22A89BE6005A860B; 77 | remoteInfo = CombineFeedback; 78 | }; 79 | /* End PBXContainerItemProxy section */ 80 | 81 | /* Begin PBXCopyFilesBuildPhase section */ 82 | 5800FFDF22A8ED0F005A860B /* Embed Frameworks */ = { 83 | isa = PBXCopyFilesBuildPhase; 84 | buildActionMask = 2147483647; 85 | dstPath = ""; 86 | dstSubfolderSpec = 10; 87 | files = ( 88 | 5800FFDC22A8ED0F005A860B /* CombineFeedback.framework in Embed Frameworks */, 89 | ); 90 | name = "Embed Frameworks"; 91 | runOnlyForDeploymentPostprocessing = 0; 92 | }; 93 | /* End PBXCopyFilesBuildPhase section */ 94 | 95 | /* Begin PBXFileReference section */ 96 | 2510CDDE242BD9FF004A6422 /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; 97 | 2510CDE0242BED63004A6422 /* StoreExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreExtensions.swift; sourceTree = ""; }; 98 | 251445E624086A400062EE04 /* TrafficLightView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficLightView.swift; sourceTree = ""; }; 99 | 252BF08322BAE05700BC4265 /* SignIn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignIn.swift; sourceTree = ""; }; 100 | 2541EA412716F459004F0CD5 /* FavouriteMovies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavouriteMovies.swift; sourceTree = ""; }; 101 | 25AE0781271056C600FF03EC /* StoreBox.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StoreBox.swift; sourceTree = ""; }; 102 | 25AE0782271056C600FF03EC /* Store.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; 103 | 25AE0786271056D600FF03EC /* ViewContext.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ViewContext.swift; sourceTree = ""; }; 104 | 25AE0787271056D600FF03EC /* SwitchStoreView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SwitchStoreView.swift; sourceTree = ""; }; 105 | 25AE0788271056D600FF03EC /* WithContextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WithContextView.swift; sourceTree = ""; }; 106 | 25AE0789271056D600FF03EC /* IfLetStoreView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = IfLetStoreView.swift; sourceTree = ""; }; 107 | 25AE078F271066FD00FF03EC /* SwitchStoreExample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchStoreExample.swift; sourceTree = ""; }; 108 | 25C57B2B22BC2C33007CB4D6 /* Activity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Activity.swift; sourceTree = ""; }; 109 | 25F23C2822CA984E00894863 /* TrafficLight.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficLight.swift; sourceTree = ""; }; 110 | 5800FF9022A89BE6005A860B /* CombineFeedback.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = CombineFeedback.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 111 | 5800FF9422A89BE6005A860B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 112 | 5800FF9922A89BE6005A860B /* CombineFeedbackTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CombineFeedbackTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 113 | 5800FF9E22A89BE6005A860B /* CombineFeedbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CombineFeedbackTests.swift; sourceTree = ""; }; 114 | 5800FFA022A89BE6005A860B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 115 | 5800FFAA22A89C08005A860B /* Feedback.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Feedback.swift; sourceTree = ""; }; 116 | 5800FFAB22A89C08005A860B /* FlatMapLatest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FlatMapLatest.swift; sourceTree = ""; }; 117 | 5800FFAC22A89C08005A860B /* System.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = System.swift; sourceTree = ""; }; 118 | 5800FFB622A8CDA5005A860B /* Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 119 | 5800FFB822A8CDA5005A860B /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 120 | 5800FFBA22A8CDA5005A860B /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 121 | 5800FFBC22A8CDA5005A860B /* Counter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Counter.swift; sourceTree = ""; }; 122 | 5800FFBE22A8CDA7005A860B /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 123 | 5800FFC122A8CDA7005A860B /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 124 | 5800FFC422A8CDA7005A860B /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 125 | 5800FFC622A8CDA7005A860B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 126 | 5800FFCB22A8CDA7005A860B /* ExampleTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ExampleTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 127 | 5800FFCF22A8CDA7005A860B /* ExampleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExampleTests.swift; sourceTree = ""; }; 128 | 5800FFD122A8CDA7005A860B /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 129 | 583971C222ADBA9900139CC0 /* PublisherExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublisherExtensions.swift; sourceTree = ""; }; 130 | 585CD868239A9D31004BE9CC /* State.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = State.swift; sourceTree = ""; }; 131 | 585CD86B239A9E34004BE9CC /* CounterState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CounterState.swift; sourceTree = ""; }; 132 | 585CD86E239A9E57004BE9CC /* MoviesState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoviesState.swift; sourceTree = ""; }; 133 | 585CD871239A9EC9004BE9CC /* SigninState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SigninState.swift; sourceTree = ""; }; 134 | 585CD874239AA1E0004BE9CC /* TrafficLightState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrafficLightState.swift; sourceTree = ""; }; 135 | 585CD876239AC19B004BE9CC /* SingleStoreExampleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStoreExampleView.swift; sourceTree = ""; }; 136 | 585CD878239AC7D3004BE9CC /* Reducer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Reducer.swift; sourceTree = ""; }; 137 | 58751A9123EC814500EEF398 /* FeedbackEventConsumer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackEventConsumer.swift; sourceTree = ""; }; 138 | 58751A9323EC819A00EEF398 /* Floodgate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Floodgate.swift; sourceTree = ""; }; 139 | 58751A9523EC823C00EEF398 /* Atomic.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Atomic.swift; sourceTree = ""; }; 140 | 58751A9723EC82D400EEF398 /* NSLockExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSLockExtensions.swift; sourceTree = ""; }; 141 | 58751A9923ECBCA000EEF398 /* FeedbackLoop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbackLoop.swift; sourceTree = ""; }; 142 | 58C6E5DF22A9F027005A9685 /* Movies.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Movies.swift; sourceTree = ""; }; 143 | 58C6E5E122AA3108005A9685 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 144 | 58C6E5E322AA3B3B005A9685 /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; 145 | 58C6E5E622AB14DB005A9685 /* AsyncImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncImage.swift; sourceTree = ""; }; 146 | 742D7D6522B02A7100A97AF3 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; 147 | /* End PBXFileReference section */ 148 | 149 | /* Begin PBXFrameworksBuildPhase section */ 150 | 5800FF8D22A89BE6005A860B /* Frameworks */ = { 151 | isa = PBXFrameworksBuildPhase; 152 | buildActionMask = 2147483647; 153 | files = ( 154 | 254B4DBA2676650000653BB8 /* CombineSchedulers in Frameworks */, 155 | 5822A2302434FEB400270514 /* CasePaths in Frameworks */, 156 | ); 157 | runOnlyForDeploymentPostprocessing = 0; 158 | }; 159 | 5800FF9622A89BE6005A860B /* Frameworks */ = { 160 | isa = PBXFrameworksBuildPhase; 161 | buildActionMask = 2147483647; 162 | files = ( 163 | 5800FF9A22A89BE6005A860B /* CombineFeedback.framework in Frameworks */, 164 | ); 165 | runOnlyForDeploymentPostprocessing = 0; 166 | }; 167 | 5800FFB322A8CDA5005A860B /* Frameworks */ = { 168 | isa = PBXFrameworksBuildPhase; 169 | buildActionMask = 2147483647; 170 | files = ( 171 | 5800FFDB22A8ED0F005A860B /* CombineFeedback.framework in Frameworks */, 172 | ); 173 | runOnlyForDeploymentPostprocessing = 0; 174 | }; 175 | 5800FFC822A8CDA7005A860B /* Frameworks */ = { 176 | isa = PBXFrameworksBuildPhase; 177 | buildActionMask = 2147483647; 178 | files = ( 179 | ); 180 | runOnlyForDeploymentPostprocessing = 0; 181 | }; 182 | /* End PBXFrameworksBuildPhase section */ 183 | 184 | /* Begin PBXGroup section */ 185 | 252BF08222BAE04300BC4265 /* SignIn */ = { 186 | isa = PBXGroup; 187 | children = ( 188 | 252BF08322BAE05700BC4265 /* SignIn.swift */, 189 | ); 190 | path = SignIn; 191 | sourceTree = ""; 192 | }; 193 | 2541EA402714A138004F0CD5 /* Internal */ = { 194 | isa = PBXGroup; 195 | children = ( 196 | 58751A9123EC814500EEF398 /* FeedbackEventConsumer.swift */, 197 | 58751A9723EC82D400EEF398 /* NSLockExtensions.swift */, 198 | 58751A9323EC819A00EEF398 /* Floodgate.swift */, 199 | 58751A9523EC823C00EEF398 /* Atomic.swift */, 200 | ); 201 | path = Internal; 202 | sourceTree = ""; 203 | }; 204 | 25AE0780271056C600FF03EC /* Store */ = { 205 | isa = PBXGroup; 206 | children = ( 207 | 25AE0781271056C600FF03EC /* StoreBox.swift */, 208 | 25AE0782271056C600FF03EC /* Store.swift */, 209 | ); 210 | path = Store; 211 | sourceTree = ""; 212 | }; 213 | 25AE0785271056D600FF03EC /* SwiftUI */ = { 214 | isa = PBXGroup; 215 | children = ( 216 | 25AE0786271056D600FF03EC /* ViewContext.swift */, 217 | 25AE0787271056D600FF03EC /* SwitchStoreView.swift */, 218 | 25AE0788271056D600FF03EC /* WithContextView.swift */, 219 | 25AE0789271056D600FF03EC /* IfLetStoreView.swift */, 220 | ); 221 | path = SwiftUI; 222 | sourceTree = ""; 223 | }; 224 | 25AE078E271066E200FF03EC /* SwitchStoreExample */ = { 225 | isa = PBXGroup; 226 | children = ( 227 | 25AE078F271066FD00FF03EC /* SwitchStoreExample.swift */, 228 | ); 229 | path = SwitchStoreExample; 230 | sourceTree = ""; 231 | }; 232 | 25F23C2722CA982400894863 /* TrafficLight */ = { 233 | isa = PBXGroup; 234 | children = ( 235 | 25F23C2822CA984E00894863 /* TrafficLight.swift */, 236 | 251445E624086A400062EE04 /* TrafficLightView.swift */, 237 | ); 238 | path = TrafficLight; 239 | sourceTree = ""; 240 | }; 241 | 5800FF8622A89BE6005A860B = { 242 | isa = PBXGroup; 243 | children = ( 244 | 742D7D6522B02A7100A97AF3 /* Package.swift */, 245 | 742D7D6222B024D100A97AF3 /* Sources */, 246 | 742D7D6322B0254600A97AF3 /* Tests */, 247 | 58C6E5E122AA3108005A9685 /* README.md */, 248 | 58C6E5E322AA3B3B005A9685 /* LICENSE */, 249 | 5800FFB722A8CDA5005A860B /* Example */, 250 | 5800FFCE22A8CDA7005A860B /* ExampleTests */, 251 | 5800FF9122A89BE6005A860B /* Products */, 252 | 5800FFDA22A8ED0F005A860B /* Frameworks */, 253 | ); 254 | sourceTree = ""; 255 | }; 256 | 5800FF9122A89BE6005A860B /* Products */ = { 257 | isa = PBXGroup; 258 | children = ( 259 | 5800FF9022A89BE6005A860B /* CombineFeedback.framework */, 260 | 5800FF9922A89BE6005A860B /* CombineFeedbackTests.xctest */, 261 | 5800FFB622A8CDA5005A860B /* Example.app */, 262 | 5800FFCB22A8CDA7005A860B /* ExampleTests.xctest */, 263 | ); 264 | name = Products; 265 | sourceTree = ""; 266 | }; 267 | 5800FF9222A89BE6005A860B /* CombineFeedback */ = { 268 | isa = PBXGroup; 269 | children = ( 270 | 2541EA402714A138004F0CD5 /* Internal */, 271 | 25AE0785271056D600FF03EC /* SwiftUI */, 272 | 25AE0780271056C600FF03EC /* Store */, 273 | 5800FFAA22A89C08005A860B /* Feedback.swift */, 274 | 58751A9923ECBCA000EEF398 /* FeedbackLoop.swift */, 275 | 5800FFAB22A89C08005A860B /* FlatMapLatest.swift */, 276 | 5800FFAC22A89C08005A860B /* System.swift */, 277 | 585CD878239AC7D3004BE9CC /* Reducer.swift */, 278 | 5800FF9422A89BE6005A860B /* Info.plist */, 279 | ); 280 | path = CombineFeedback; 281 | sourceTree = ""; 282 | }; 283 | 5800FF9D22A89BE6005A860B /* CombineFeedbackTests */ = { 284 | isa = PBXGroup; 285 | children = ( 286 | 5800FF9E22A89BE6005A860B /* CombineFeedbackTests.swift */, 287 | 5800FFA022A89BE6005A860B /* Info.plist */, 288 | ); 289 | path = CombineFeedbackTests; 290 | sourceTree = ""; 291 | }; 292 | 5800FFB722A8CDA5005A860B /* Example */ = { 293 | isa = PBXGroup; 294 | children = ( 295 | 585CD867239A9D15004BE9CC /* SingleStoreExample */, 296 | 583971BD22ADB9DF00139CC0 /* Helpers */, 297 | 58C6E5E522AB149D005A9685 /* Views */, 298 | 58C6E5DE22A9EFDF005A9685 /* CounterExample */, 299 | 58C6E5DD22A9EFCE005A9685 /* MoviesExample */, 300 | 252BF08222BAE04300BC4265 /* SignIn */, 301 | 25F23C2722CA982400894863 /* TrafficLight */, 302 | 5800FFB822A8CDA5005A860B /* AppDelegate.swift */, 303 | 5800FFBA22A8CDA5005A860B /* SceneDelegate.swift */, 304 | 5800FFBE22A8CDA7005A860B /* Assets.xcassets */, 305 | 5800FFC322A8CDA7005A860B /* LaunchScreen.storyboard */, 306 | 5800FFC622A8CDA7005A860B /* Info.plist */, 307 | 5800FFC022A8CDA7005A860B /* Preview Content */, 308 | ); 309 | path = Example; 310 | sourceTree = ""; 311 | }; 312 | 5800FFC022A8CDA7005A860B /* Preview Content */ = { 313 | isa = PBXGroup; 314 | children = ( 315 | 5800FFC122A8CDA7005A860B /* Preview Assets.xcassets */, 316 | ); 317 | path = "Preview Content"; 318 | sourceTree = ""; 319 | }; 320 | 5800FFCE22A8CDA7005A860B /* ExampleTests */ = { 321 | isa = PBXGroup; 322 | children = ( 323 | 5800FFCF22A8CDA7005A860B /* ExampleTests.swift */, 324 | 5800FFD122A8CDA7005A860B /* Info.plist */, 325 | ); 326 | path = ExampleTests; 327 | sourceTree = ""; 328 | }; 329 | 5800FFDA22A8ED0F005A860B /* Frameworks */ = { 330 | isa = PBXGroup; 331 | children = ( 332 | ); 333 | name = Frameworks; 334 | sourceTree = ""; 335 | }; 336 | 583971BD22ADB9DF00139CC0 /* Helpers */ = { 337 | isa = PBXGroup; 338 | children = ( 339 | 583971C222ADBA9900139CC0 /* PublisherExtensions.swift */, 340 | 2510CDDE242BD9FF004A6422 /* Log.swift */, 341 | ); 342 | path = Helpers; 343 | sourceTree = ""; 344 | }; 345 | 585CD867239A9D15004BE9CC /* SingleStoreExample */ = { 346 | isa = PBXGroup; 347 | children = ( 348 | 25AE078E271066E200FF03EC /* SwitchStoreExample */, 349 | 585CD873239AA0D3004BE9CC /* TrafficLight */, 350 | 585CD870239A9EBE004BE9CC /* Signin */, 351 | 585CD86D239A9E44004BE9CC /* Movies */, 352 | 585CD86A239A9E23004BE9CC /* Counter */, 353 | 585CD868239A9D31004BE9CC /* State.swift */, 354 | 585CD876239AC19B004BE9CC /* SingleStoreExampleView.swift */, 355 | ); 356 | path = SingleStoreExample; 357 | sourceTree = ""; 358 | }; 359 | 585CD86A239A9E23004BE9CC /* Counter */ = { 360 | isa = PBXGroup; 361 | children = ( 362 | 585CD86B239A9E34004BE9CC /* CounterState.swift */, 363 | ); 364 | path = Counter; 365 | sourceTree = ""; 366 | }; 367 | 585CD86D239A9E44004BE9CC /* Movies */ = { 368 | isa = PBXGroup; 369 | children = ( 370 | 585CD86E239A9E57004BE9CC /* MoviesState.swift */, 371 | 2541EA412716F459004F0CD5 /* FavouriteMovies.swift */, 372 | ); 373 | path = Movies; 374 | sourceTree = ""; 375 | }; 376 | 585CD870239A9EBE004BE9CC /* Signin */ = { 377 | isa = PBXGroup; 378 | children = ( 379 | 585CD871239A9EC9004BE9CC /* SigninState.swift */, 380 | ); 381 | path = Signin; 382 | sourceTree = ""; 383 | }; 384 | 585CD873239AA0D3004BE9CC /* TrafficLight */ = { 385 | isa = PBXGroup; 386 | children = ( 387 | 585CD874239AA1E0004BE9CC /* TrafficLightState.swift */, 388 | ); 389 | path = TrafficLight; 390 | sourceTree = ""; 391 | }; 392 | 58C6E5DD22A9EFCE005A9685 /* MoviesExample */ = { 393 | isa = PBXGroup; 394 | children = ( 395 | 58C6E5DF22A9F027005A9685 /* Movies.swift */, 396 | ); 397 | path = MoviesExample; 398 | sourceTree = ""; 399 | }; 400 | 58C6E5DE22A9EFDF005A9685 /* CounterExample */ = { 401 | isa = PBXGroup; 402 | children = ( 403 | 5800FFBC22A8CDA5005A860B /* Counter.swift */, 404 | ); 405 | path = CounterExample; 406 | sourceTree = ""; 407 | }; 408 | 58C6E5E522AB149D005A9685 /* Views */ = { 409 | isa = PBXGroup; 410 | children = ( 411 | 58C6E5E622AB14DB005A9685 /* AsyncImage.swift */, 412 | 25C57B2B22BC2C33007CB4D6 /* Activity.swift */, 413 | 2510CDE0242BED63004A6422 /* StoreExtensions.swift */, 414 | ); 415 | path = Views; 416 | sourceTree = ""; 417 | }; 418 | 742D7D6222B024D100A97AF3 /* Sources */ = { 419 | isa = PBXGroup; 420 | children = ( 421 | 5800FF9222A89BE6005A860B /* CombineFeedback */, 422 | ); 423 | path = Sources; 424 | sourceTree = ""; 425 | }; 426 | 742D7D6322B0254600A97AF3 /* Tests */ = { 427 | isa = PBXGroup; 428 | children = ( 429 | 5800FF9D22A89BE6005A860B /* CombineFeedbackTests */, 430 | ); 431 | path = Tests; 432 | sourceTree = ""; 433 | }; 434 | /* End PBXGroup section */ 435 | 436 | /* Begin PBXHeadersBuildPhase section */ 437 | 5800FF8B22A89BE6005A860B /* Headers */ = { 438 | isa = PBXHeadersBuildPhase; 439 | buildActionMask = 2147483647; 440 | files = ( 441 | ); 442 | runOnlyForDeploymentPostprocessing = 0; 443 | }; 444 | /* End PBXHeadersBuildPhase section */ 445 | 446 | /* Begin PBXNativeTarget section */ 447 | 5800FF8F22A89BE6005A860B /* CombineFeedback */ = { 448 | isa = PBXNativeTarget; 449 | buildConfigurationList = 5800FFA422A89BE6005A860B /* Build configuration list for PBXNativeTarget "CombineFeedback" */; 450 | buildPhases = ( 451 | 5800FF8B22A89BE6005A860B /* Headers */, 452 | 5800FF8C22A89BE6005A860B /* Sources */, 453 | 5800FF8D22A89BE6005A860B /* Frameworks */, 454 | 5800FF8E22A89BE6005A860B /* Resources */, 455 | ); 456 | buildRules = ( 457 | ); 458 | dependencies = ( 459 | ); 460 | name = CombineFeedback; 461 | packageProductDependencies = ( 462 | 5822A22F2434FEB400270514 /* CasePaths */, 463 | 254B4DB92676650000653BB8 /* CombineSchedulers */, 464 | ); 465 | productName = CombineFeedback; 466 | productReference = 5800FF9022A89BE6005A860B /* CombineFeedback.framework */; 467 | productType = "com.apple.product-type.framework"; 468 | }; 469 | 5800FF9822A89BE6005A860B /* CombineFeedbackTests */ = { 470 | isa = PBXNativeTarget; 471 | buildConfigurationList = 5800FFA722A89BE6005A860B /* Build configuration list for PBXNativeTarget "CombineFeedbackTests" */; 472 | buildPhases = ( 473 | 5800FF9522A89BE6005A860B /* Sources */, 474 | 5800FF9622A89BE6005A860B /* Frameworks */, 475 | 5800FF9722A89BE6005A860B /* Resources */, 476 | ); 477 | buildRules = ( 478 | ); 479 | dependencies = ( 480 | 5800FF9C22A89BE6005A860B /* PBXTargetDependency */, 481 | ); 482 | name = CombineFeedbackTests; 483 | packageProductDependencies = ( 484 | ); 485 | productName = CombineFeedbackTests; 486 | productReference = 5800FF9922A89BE6005A860B /* CombineFeedbackTests.xctest */; 487 | productType = "com.apple.product-type.bundle.unit-test"; 488 | }; 489 | 5800FFB522A8CDA5005A860B /* Example */ = { 490 | isa = PBXNativeTarget; 491 | buildConfigurationList = 5800FFD222A8CDA7005A860B /* Build configuration list for PBXNativeTarget "Example" */; 492 | buildPhases = ( 493 | 5800FFB222A8CDA5005A860B /* Sources */, 494 | 5800FFB322A8CDA5005A860B /* Frameworks */, 495 | 5800FFB422A8CDA5005A860B /* Resources */, 496 | 5800FFDF22A8ED0F005A860B /* Embed Frameworks */, 497 | ); 498 | buildRules = ( 499 | ); 500 | dependencies = ( 501 | 5800FFDE22A8ED0F005A860B /* PBXTargetDependency */, 502 | ); 503 | name = Example; 504 | productName = Example; 505 | productReference = 5800FFB622A8CDA5005A860B /* Example.app */; 506 | productType = "com.apple.product-type.application"; 507 | }; 508 | 5800FFCA22A8CDA7005A860B /* ExampleTests */ = { 509 | isa = PBXNativeTarget; 510 | buildConfigurationList = 5800FFD522A8CDA7005A860B /* Build configuration list for PBXNativeTarget "ExampleTests" */; 511 | buildPhases = ( 512 | 5800FFC722A8CDA7005A860B /* Sources */, 513 | 5800FFC822A8CDA7005A860B /* Frameworks */, 514 | 5800FFC922A8CDA7005A860B /* Resources */, 515 | ); 516 | buildRules = ( 517 | ); 518 | dependencies = ( 519 | 5800FFCD22A8CDA7005A860B /* PBXTargetDependency */, 520 | ); 521 | name = ExampleTests; 522 | productName = ExampleTests; 523 | productReference = 5800FFCB22A8CDA7005A860B /* ExampleTests.xctest */; 524 | productType = "com.apple.product-type.bundle.unit-test"; 525 | }; 526 | /* End PBXNativeTarget section */ 527 | 528 | /* Begin PBXProject section */ 529 | 5800FF8722A89BE6005A860B /* Project object */ = { 530 | isa = PBXProject; 531 | attributes = { 532 | LastSwiftUpdateCheck = 1100; 533 | LastUpgradeCheck = 1300; 534 | ORGANIZATIONNAME = babylonhealth; 535 | TargetAttributes = { 536 | 5800FF8F22A89BE6005A860B = { 537 | CreatedOnToolsVersion = 11.0; 538 | LastSwiftMigration = 1100; 539 | }; 540 | 5800FF9822A89BE6005A860B = { 541 | CreatedOnToolsVersion = 11.0; 542 | }; 543 | 5800FFB522A8CDA5005A860B = { 544 | CreatedOnToolsVersion = 11.0; 545 | }; 546 | 5800FFCA22A8CDA7005A860B = { 547 | CreatedOnToolsVersion = 11.0; 548 | TestTargetID = 5800FFB522A8CDA5005A860B; 549 | }; 550 | }; 551 | }; 552 | buildConfigurationList = 5800FF8A22A89BE6005A860B /* Build configuration list for PBXProject "CombineFeedback" */; 553 | compatibilityVersion = "Xcode 9.3"; 554 | developmentRegion = en; 555 | hasScannedForEncodings = 0; 556 | knownRegions = ( 557 | en, 558 | Base, 559 | ); 560 | mainGroup = 5800FF8622A89BE6005A860B; 561 | packageReferences = ( 562 | 5822A22E2434FEB400270514 /* XCRemoteSwiftPackageReference "swift-case-paths" */, 563 | 254B4DB82676650000653BB8 /* XCRemoteSwiftPackageReference "combine-schedulers" */, 564 | ); 565 | productRefGroup = 5800FF9122A89BE6005A860B /* Products */; 566 | projectDirPath = ""; 567 | projectRoot = ""; 568 | targets = ( 569 | 5800FF8F22A89BE6005A860B /* CombineFeedback */, 570 | 5800FF9822A89BE6005A860B /* CombineFeedbackTests */, 571 | 5800FFB522A8CDA5005A860B /* Example */, 572 | 5800FFCA22A8CDA7005A860B /* ExampleTests */, 573 | ); 574 | }; 575 | /* End PBXProject section */ 576 | 577 | /* Begin PBXResourcesBuildPhase section */ 578 | 5800FF8E22A89BE6005A860B /* Resources */ = { 579 | isa = PBXResourcesBuildPhase; 580 | buildActionMask = 2147483647; 581 | files = ( 582 | ); 583 | runOnlyForDeploymentPostprocessing = 0; 584 | }; 585 | 5800FF9722A89BE6005A860B /* Resources */ = { 586 | isa = PBXResourcesBuildPhase; 587 | buildActionMask = 2147483647; 588 | files = ( 589 | ); 590 | runOnlyForDeploymentPostprocessing = 0; 591 | }; 592 | 5800FFB422A8CDA5005A860B /* Resources */ = { 593 | isa = PBXResourcesBuildPhase; 594 | buildActionMask = 2147483647; 595 | files = ( 596 | 5800FFC522A8CDA7005A860B /* LaunchScreen.storyboard in Resources */, 597 | 5800FFC222A8CDA7005A860B /* Preview Assets.xcassets in Resources */, 598 | 5800FFBF22A8CDA7005A860B /* Assets.xcassets in Resources */, 599 | ); 600 | runOnlyForDeploymentPostprocessing = 0; 601 | }; 602 | 5800FFC922A8CDA7005A860B /* Resources */ = { 603 | isa = PBXResourcesBuildPhase; 604 | buildActionMask = 2147483647; 605 | files = ( 606 | ); 607 | runOnlyForDeploymentPostprocessing = 0; 608 | }; 609 | /* End PBXResourcesBuildPhase section */ 610 | 611 | /* Begin PBXSourcesBuildPhase section */ 612 | 5800FF8C22A89BE6005A860B /* Sources */ = { 613 | isa = PBXSourcesBuildPhase; 614 | buildActionMask = 2147483647; 615 | files = ( 616 | 25AE078C271056D600FF03EC /* WithContextView.swift in Sources */, 617 | 5800FFAF22A89C09005A860B /* FlatMapLatest.swift in Sources */, 618 | 25AE078B271056D600FF03EC /* SwitchStoreView.swift in Sources */, 619 | 58751A9423EC819A00EEF398 /* Floodgate.swift in Sources */, 620 | 58751A9223EC814600EEF398 /* FeedbackEventConsumer.swift in Sources */, 621 | 5800FFB022A89C09005A860B /* System.swift in Sources */, 622 | 25AE078A271056D600FF03EC /* ViewContext.swift in Sources */, 623 | 58751A9A23ECBCA000EEF398 /* FeedbackLoop.swift in Sources */, 624 | 25EBC08C23FD61B100719826 /* Reducer.swift in Sources */, 625 | 25AE0784271056C600FF03EC /* Store.swift in Sources */, 626 | 58751A9623EC823C00EEF398 /* Atomic.swift in Sources */, 627 | 5800FFAE22A89C09005A860B /* Feedback.swift in Sources */, 628 | 58751A9823EC82D400EEF398 /* NSLockExtensions.swift in Sources */, 629 | 25AE0783271056C600FF03EC /* StoreBox.swift in Sources */, 630 | 25AE078D271056D600FF03EC /* IfLetStoreView.swift in Sources */, 631 | ); 632 | runOnlyForDeploymentPostprocessing = 0; 633 | }; 634 | 5800FF9522A89BE6005A860B /* Sources */ = { 635 | isa = PBXSourcesBuildPhase; 636 | buildActionMask = 2147483647; 637 | files = ( 638 | 5800FF9F22A89BE6005A860B /* CombineFeedbackTests.swift in Sources */, 639 | ); 640 | runOnlyForDeploymentPostprocessing = 0; 641 | }; 642 | 5800FFB222A8CDA5005A860B /* Sources */ = { 643 | isa = PBXSourcesBuildPhase; 644 | buildActionMask = 2147483647; 645 | files = ( 646 | 585CD86F239A9E57004BE9CC /* MoviesState.swift in Sources */, 647 | 585CD877239AC19B004BE9CC /* SingleStoreExampleView.swift in Sources */, 648 | 2510CDDF242BD9FF004A6422 /* Log.swift in Sources */, 649 | 585CD872239A9EC9004BE9CC /* SigninState.swift in Sources */, 650 | 25C57B2C22BC2C33007CB4D6 /* Activity.swift in Sources */, 651 | 58C6E5E022A9F027005A9685 /* Movies.swift in Sources */, 652 | 585CD875239AA1E0004BE9CC /* TrafficLightState.swift in Sources */, 653 | 585CD86C239A9E34004BE9CC /* CounterState.swift in Sources */, 654 | 2541EA422716F459004F0CD5 /* FavouriteMovies.swift in Sources */, 655 | 252BF08422BAE05700BC4265 /* SignIn.swift in Sources */, 656 | 585CD869239A9D31004BE9CC /* State.swift in Sources */, 657 | 5800FFB922A8CDA5005A860B /* AppDelegate.swift in Sources */, 658 | 25AE0790271066FD00FF03EC /* SwitchStoreExample.swift in Sources */, 659 | 5800FFBB22A8CDA5005A860B /* SceneDelegate.swift in Sources */, 660 | 583971C322ADBA9900139CC0 /* PublisherExtensions.swift in Sources */, 661 | 2510CDE1242BED63004A6422 /* StoreExtensions.swift in Sources */, 662 | 58C6E5E722AB14DB005A9685 /* AsyncImage.swift in Sources */, 663 | 5800FFBD22A8CDA5005A860B /* Counter.swift in Sources */, 664 | 25F23C2922CA984E00894863 /* TrafficLight.swift in Sources */, 665 | 251445E724086A400062EE04 /* TrafficLightView.swift in Sources */, 666 | ); 667 | runOnlyForDeploymentPostprocessing = 0; 668 | }; 669 | 5800FFC722A8CDA7005A860B /* Sources */ = { 670 | isa = PBXSourcesBuildPhase; 671 | buildActionMask = 2147483647; 672 | files = ( 673 | 5800FFD022A8CDA7005A860B /* ExampleTests.swift in Sources */, 674 | ); 675 | runOnlyForDeploymentPostprocessing = 0; 676 | }; 677 | /* End PBXSourcesBuildPhase section */ 678 | 679 | /* Begin PBXTargetDependency section */ 680 | 5800FF9C22A89BE6005A860B /* PBXTargetDependency */ = { 681 | isa = PBXTargetDependency; 682 | target = 5800FF8F22A89BE6005A860B /* CombineFeedback */; 683 | targetProxy = 5800FF9B22A89BE6005A860B /* PBXContainerItemProxy */; 684 | }; 685 | 5800FFCD22A8CDA7005A860B /* PBXTargetDependency */ = { 686 | isa = PBXTargetDependency; 687 | target = 5800FFB522A8CDA5005A860B /* Example */; 688 | targetProxy = 5800FFCC22A8CDA7005A860B /* PBXContainerItemProxy */; 689 | }; 690 | 5800FFDE22A8ED0F005A860B /* PBXTargetDependency */ = { 691 | isa = PBXTargetDependency; 692 | target = 5800FF8F22A89BE6005A860B /* CombineFeedback */; 693 | targetProxy = 5800FFDD22A8ED0F005A860B /* PBXContainerItemProxy */; 694 | }; 695 | /* End PBXTargetDependency section */ 696 | 697 | /* Begin PBXVariantGroup section */ 698 | 5800FFC322A8CDA7005A860B /* LaunchScreen.storyboard */ = { 699 | isa = PBXVariantGroup; 700 | children = ( 701 | 5800FFC422A8CDA7005A860B /* Base */, 702 | ); 703 | name = LaunchScreen.storyboard; 704 | sourceTree = ""; 705 | }; 706 | /* End PBXVariantGroup section */ 707 | 708 | /* Begin XCBuildConfiguration section */ 709 | 5800FFA222A89BE6005A860B /* Debug */ = { 710 | isa = XCBuildConfiguration; 711 | buildSettings = { 712 | ALWAYS_SEARCH_USER_PATHS = NO; 713 | CLANG_ANALYZER_NONNULL = YES; 714 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 715 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 716 | CLANG_CXX_LIBRARY = "libc++"; 717 | CLANG_ENABLE_MODULES = YES; 718 | CLANG_ENABLE_OBJC_ARC = YES; 719 | CLANG_ENABLE_OBJC_WEAK = YES; 720 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 721 | CLANG_WARN_BOOL_CONVERSION = YES; 722 | CLANG_WARN_COMMA = YES; 723 | CLANG_WARN_CONSTANT_CONVERSION = YES; 724 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 725 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 726 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 727 | CLANG_WARN_EMPTY_BODY = YES; 728 | CLANG_WARN_ENUM_CONVERSION = YES; 729 | CLANG_WARN_INFINITE_RECURSION = YES; 730 | CLANG_WARN_INT_CONVERSION = YES; 731 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 732 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 733 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 734 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 735 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 736 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 737 | CLANG_WARN_STRICT_PROTOTYPES = YES; 738 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 739 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 740 | CLANG_WARN_UNREACHABLE_CODE = YES; 741 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 742 | COPY_PHASE_STRIP = NO; 743 | CURRENT_PROJECT_VERSION = 1; 744 | DEBUG_INFORMATION_FORMAT = dwarf; 745 | ENABLE_STRICT_OBJC_MSGSEND = YES; 746 | ENABLE_TESTABILITY = YES; 747 | GCC_C_LANGUAGE_STANDARD = gnu11; 748 | GCC_DYNAMIC_NO_PIC = NO; 749 | GCC_NO_COMMON_BLOCKS = YES; 750 | GCC_OPTIMIZATION_LEVEL = 0; 751 | GCC_PREPROCESSOR_DEFINITIONS = ( 752 | "DEBUG=1", 753 | "$(inherited)", 754 | ); 755 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 756 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 757 | GCC_WARN_UNDECLARED_SELECTOR = YES; 758 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 759 | GCC_WARN_UNUSED_FUNCTION = YES; 760 | GCC_WARN_UNUSED_VARIABLE = YES; 761 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 762 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 763 | MTL_FAST_MATH = YES; 764 | ONLY_ACTIVE_ARCH = YES; 765 | SDKROOT = iphoneos; 766 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 767 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 768 | VERSIONING_SYSTEM = "apple-generic"; 769 | VERSION_INFO_PREFIX = ""; 770 | }; 771 | name = Debug; 772 | }; 773 | 5800FFA322A89BE6005A860B /* Release */ = { 774 | isa = XCBuildConfiguration; 775 | buildSettings = { 776 | ALWAYS_SEARCH_USER_PATHS = NO; 777 | CLANG_ANALYZER_NONNULL = YES; 778 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 779 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 780 | CLANG_CXX_LIBRARY = "libc++"; 781 | CLANG_ENABLE_MODULES = YES; 782 | CLANG_ENABLE_OBJC_ARC = YES; 783 | CLANG_ENABLE_OBJC_WEAK = YES; 784 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 785 | CLANG_WARN_BOOL_CONVERSION = YES; 786 | CLANG_WARN_COMMA = YES; 787 | CLANG_WARN_CONSTANT_CONVERSION = YES; 788 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 789 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 790 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 791 | CLANG_WARN_EMPTY_BODY = YES; 792 | CLANG_WARN_ENUM_CONVERSION = YES; 793 | CLANG_WARN_INFINITE_RECURSION = YES; 794 | CLANG_WARN_INT_CONVERSION = YES; 795 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 796 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 797 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 798 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 799 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 800 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 801 | CLANG_WARN_STRICT_PROTOTYPES = YES; 802 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 803 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 804 | CLANG_WARN_UNREACHABLE_CODE = YES; 805 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 806 | COPY_PHASE_STRIP = NO; 807 | CURRENT_PROJECT_VERSION = 1; 808 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 809 | ENABLE_NS_ASSERTIONS = NO; 810 | ENABLE_STRICT_OBJC_MSGSEND = YES; 811 | GCC_C_LANGUAGE_STANDARD = gnu11; 812 | GCC_NO_COMMON_BLOCKS = YES; 813 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 814 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 815 | GCC_WARN_UNDECLARED_SELECTOR = YES; 816 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 817 | GCC_WARN_UNUSED_FUNCTION = YES; 818 | GCC_WARN_UNUSED_VARIABLE = YES; 819 | IPHONEOS_DEPLOYMENT_TARGET = 13.0; 820 | MTL_ENABLE_DEBUG_INFO = NO; 821 | MTL_FAST_MATH = YES; 822 | SDKROOT = iphoneos; 823 | SWIFT_COMPILATION_MODE = wholemodule; 824 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 825 | VALIDATE_PRODUCT = YES; 826 | VERSIONING_SYSTEM = "apple-generic"; 827 | VERSION_INFO_PREFIX = ""; 828 | }; 829 | name = Release; 830 | }; 831 | 5800FFA522A89BE6005A860B /* Debug */ = { 832 | isa = XCBuildConfiguration; 833 | buildSettings = { 834 | CLANG_ENABLE_MODULES = YES; 835 | CODE_SIGN_STYLE = Automatic; 836 | DEFINES_MODULE = YES; 837 | DYLIB_COMPATIBILITY_VERSION = 1; 838 | DYLIB_CURRENT_VERSION = 1; 839 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 840 | INFOPLIST_FILE = Sources/CombineFeedback/Info.plist; 841 | INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; 842 | LD_RUNPATH_SEARCH_PATHS = ( 843 | "$(inherited)", 844 | "@executable_path/Frameworks", 845 | "@loader_path/Frameworks", 846 | ); 847 | PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.CombineFeedback; 848 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 849 | SKIP_INSTALL = YES; 850 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 851 | SWIFT_VERSION = 5.0; 852 | TARGETED_DEVICE_FAMILY = "1,2"; 853 | }; 854 | name = Debug; 855 | }; 856 | 5800FFA622A89BE6005A860B /* Release */ = { 857 | isa = XCBuildConfiguration; 858 | buildSettings = { 859 | CLANG_ENABLE_MODULES = YES; 860 | CODE_SIGN_STYLE = Automatic; 861 | DEFINES_MODULE = YES; 862 | DYLIB_COMPATIBILITY_VERSION = 1; 863 | DYLIB_CURRENT_VERSION = 1; 864 | DYLIB_INSTALL_NAME_BASE = "@rpath"; 865 | INFOPLIST_FILE = Sources/CombineFeedback/Info.plist; 866 | INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; 867 | LD_RUNPATH_SEARCH_PATHS = ( 868 | "$(inherited)", 869 | "@executable_path/Frameworks", 870 | "@loader_path/Frameworks", 871 | ); 872 | PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.CombineFeedback; 873 | PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; 874 | SKIP_INSTALL = YES; 875 | SWIFT_VERSION = 5.0; 876 | TARGETED_DEVICE_FAMILY = "1,2"; 877 | }; 878 | name = Release; 879 | }; 880 | 5800FFA822A89BE6005A860B /* Debug */ = { 881 | isa = XCBuildConfiguration; 882 | buildSettings = { 883 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 884 | CODE_SIGN_STYLE = Automatic; 885 | INFOPLIST_FILE = Tests/CombineFeedbackTests/Info.plist; 886 | LD_RUNPATH_SEARCH_PATHS = ( 887 | "$(inherited)", 888 | "@executable_path/Frameworks", 889 | "@loader_path/Frameworks", 890 | ); 891 | PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.CombineFeedbackTests; 892 | PRODUCT_NAME = "$(TARGET_NAME)"; 893 | SWIFT_VERSION = 5.0; 894 | TARGETED_DEVICE_FAMILY = "1,2"; 895 | }; 896 | name = Debug; 897 | }; 898 | 5800FFA922A89BE6005A860B /* Release */ = { 899 | isa = XCBuildConfiguration; 900 | buildSettings = { 901 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 902 | CODE_SIGN_STYLE = Automatic; 903 | INFOPLIST_FILE = Tests/CombineFeedbackTests/Info.plist; 904 | LD_RUNPATH_SEARCH_PATHS = ( 905 | "$(inherited)", 906 | "@executable_path/Frameworks", 907 | "@loader_path/Frameworks", 908 | ); 909 | PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.CombineFeedbackTests; 910 | PRODUCT_NAME = "$(TARGET_NAME)"; 911 | SWIFT_VERSION = 5.0; 912 | TARGETED_DEVICE_FAMILY = "1,2"; 913 | }; 914 | name = Release; 915 | }; 916 | 5800FFD322A8CDA7005A860B /* Debug */ = { 917 | isa = XCBuildConfiguration; 918 | buildSettings = { 919 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 920 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 921 | CODE_SIGN_STYLE = Automatic; 922 | DEVELOPMENT_ASSET_PATHS = "Example/Preview\\ Content"; 923 | DEVELOPMENT_TEAM = 3JZ7RUJD4Q; 924 | ENABLE_PREVIEWS = YES; 925 | INFOPLIST_FILE = Example/Info.plist; 926 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 927 | LD_RUNPATH_SEARCH_PATHS = ( 928 | "$(inherited)", 929 | "@executable_path/Frameworks", 930 | ); 931 | PRODUCT_BUNDLE_IDENTIFIER = com.sergdort.combinefeedback.example2; 932 | PRODUCT_NAME = "$(TARGET_NAME)"; 933 | SWIFT_VERSION = 5.0; 934 | TARGETED_DEVICE_FAMILY = "1,2"; 935 | }; 936 | name = Debug; 937 | }; 938 | 5800FFD422A8CDA7005A860B /* Release */ = { 939 | isa = XCBuildConfiguration; 940 | buildSettings = { 941 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 942 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 943 | CODE_SIGN_STYLE = Automatic; 944 | DEVELOPMENT_ASSET_PATHS = "Example/Preview\\ Content"; 945 | DEVELOPMENT_TEAM = 3JZ7RUJD4Q; 946 | ENABLE_PREVIEWS = YES; 947 | INFOPLIST_FILE = Example/Info.plist; 948 | IPHONEOS_DEPLOYMENT_TARGET = 15.0; 949 | LD_RUNPATH_SEARCH_PATHS = ( 950 | "$(inherited)", 951 | "@executable_path/Frameworks", 952 | ); 953 | PRODUCT_BUNDLE_IDENTIFIER = com.sergdort.combinefeedback.example2; 954 | PRODUCT_NAME = "$(TARGET_NAME)"; 955 | SWIFT_VERSION = 5.0; 956 | TARGETED_DEVICE_FAMILY = "1,2"; 957 | }; 958 | name = Release; 959 | }; 960 | 5800FFD622A8CDA7005A860B /* Debug */ = { 961 | isa = XCBuildConfiguration; 962 | buildSettings = { 963 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 964 | BUNDLE_LOADER = "$(TEST_HOST)"; 965 | CODE_SIGN_STYLE = Automatic; 966 | INFOPLIST_FILE = ExampleTests/Info.plist; 967 | LD_RUNPATH_SEARCH_PATHS = ( 968 | "$(inherited)", 969 | "@executable_path/Frameworks", 970 | "@loader_path/Frameworks", 971 | ); 972 | PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.ExampleTests; 973 | PRODUCT_NAME = "$(TARGET_NAME)"; 974 | SWIFT_VERSION = 5.0; 975 | TARGETED_DEVICE_FAMILY = "1,2"; 976 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Example.app/Example"; 977 | }; 978 | name = Debug; 979 | }; 980 | 5800FFD722A8CDA7005A860B /* Release */ = { 981 | isa = XCBuildConfiguration; 982 | buildSettings = { 983 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 984 | BUNDLE_LOADER = "$(TEST_HOST)"; 985 | CODE_SIGN_STYLE = Automatic; 986 | INFOPLIST_FILE = ExampleTests/Info.plist; 987 | LD_RUNPATH_SEARCH_PATHS = ( 988 | "$(inherited)", 989 | "@executable_path/Frameworks", 990 | "@loader_path/Frameworks", 991 | ); 992 | PRODUCT_BUNDLE_IDENTIFIER = com.babylonhealth.ExampleTests; 993 | PRODUCT_NAME = "$(TARGET_NAME)"; 994 | SWIFT_VERSION = 5.0; 995 | TARGETED_DEVICE_FAMILY = "1,2"; 996 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Example.app/Example"; 997 | }; 998 | name = Release; 999 | }; 1000 | /* End XCBuildConfiguration section */ 1001 | 1002 | /* Begin XCConfigurationList section */ 1003 | 5800FF8A22A89BE6005A860B /* Build configuration list for PBXProject "CombineFeedback" */ = { 1004 | isa = XCConfigurationList; 1005 | buildConfigurations = ( 1006 | 5800FFA222A89BE6005A860B /* Debug */, 1007 | 5800FFA322A89BE6005A860B /* Release */, 1008 | ); 1009 | defaultConfigurationIsVisible = 0; 1010 | defaultConfigurationName = Release; 1011 | }; 1012 | 5800FFA422A89BE6005A860B /* Build configuration list for PBXNativeTarget "CombineFeedback" */ = { 1013 | isa = XCConfigurationList; 1014 | buildConfigurations = ( 1015 | 5800FFA522A89BE6005A860B /* Debug */, 1016 | 5800FFA622A89BE6005A860B /* Release */, 1017 | ); 1018 | defaultConfigurationIsVisible = 0; 1019 | defaultConfigurationName = Release; 1020 | }; 1021 | 5800FFA722A89BE6005A860B /* Build configuration list for PBXNativeTarget "CombineFeedbackTests" */ = { 1022 | isa = XCConfigurationList; 1023 | buildConfigurations = ( 1024 | 5800FFA822A89BE6005A860B /* Debug */, 1025 | 5800FFA922A89BE6005A860B /* Release */, 1026 | ); 1027 | defaultConfigurationIsVisible = 0; 1028 | defaultConfigurationName = Release; 1029 | }; 1030 | 5800FFD222A8CDA7005A860B /* Build configuration list for PBXNativeTarget "Example" */ = { 1031 | isa = XCConfigurationList; 1032 | buildConfigurations = ( 1033 | 5800FFD322A8CDA7005A860B /* Debug */, 1034 | 5800FFD422A8CDA7005A860B /* Release */, 1035 | ); 1036 | defaultConfigurationIsVisible = 0; 1037 | defaultConfigurationName = Release; 1038 | }; 1039 | 5800FFD522A8CDA7005A860B /* Build configuration list for PBXNativeTarget "ExampleTests" */ = { 1040 | isa = XCConfigurationList; 1041 | buildConfigurations = ( 1042 | 5800FFD622A8CDA7005A860B /* Debug */, 1043 | 5800FFD722A8CDA7005A860B /* Release */, 1044 | ); 1045 | defaultConfigurationIsVisible = 0; 1046 | defaultConfigurationName = Release; 1047 | }; 1048 | /* End XCConfigurationList section */ 1049 | 1050 | /* Begin XCRemoteSwiftPackageReference section */ 1051 | 254B4DB82676650000653BB8 /* XCRemoteSwiftPackageReference "combine-schedulers" */ = { 1052 | isa = XCRemoteSwiftPackageReference; 1053 | repositoryURL = "https://github.com/pointfreeco/combine-schedulers.git"; 1054 | requirement = { 1055 | kind = upToNextMajorVersion; 1056 | minimumVersion = 0.5.0; 1057 | }; 1058 | }; 1059 | 5822A22E2434FEB400270514 /* XCRemoteSwiftPackageReference "swift-case-paths" */ = { 1060 | isa = XCRemoteSwiftPackageReference; 1061 | repositoryURL = "git@github.com:pointfreeco/swift-case-paths.git"; 1062 | requirement = { 1063 | kind = upToNextMajorVersion; 1064 | minimumVersion = 0.2.0; 1065 | }; 1066 | }; 1067 | /* End XCRemoteSwiftPackageReference section */ 1068 | 1069 | /* Begin XCSwiftPackageProductDependency section */ 1070 | 254B4DB92676650000653BB8 /* CombineSchedulers */ = { 1071 | isa = XCSwiftPackageProductDependency; 1072 | package = 254B4DB82676650000653BB8 /* XCRemoteSwiftPackageReference "combine-schedulers" */; 1073 | productName = CombineSchedulers; 1074 | }; 1075 | 5822A22F2434FEB400270514 /* CasePaths */ = { 1076 | isa = XCSwiftPackageProductDependency; 1077 | package = 5822A22E2434FEB400270514 /* XCRemoteSwiftPackageReference "swift-case-paths" */; 1078 | productName = CasePaths; 1079 | }; 1080 | /* End XCSwiftPackageProductDependency section */ 1081 | }; 1082 | rootObject = 5800FF8722A89BE6005A860B /* Project object */; 1083 | } 1084 | --------------------------------------------------------------------------------