diff --git a/Example/Photos/PhotoListOriginal.swift b/Example/Photos/Original/PhotoListOriginal.swift similarity index 100% rename from Example/Photos/PhotoListOriginal.swift rename to Example/Photos/Original/PhotoListOriginal.swift diff --git a/Example/Photos/With Stores/Banner Feature/Banner.swift b/Example/Photos/With Stores/Banner Feature/Banner.swift new file mode 100644 index 0000000..953d55b --- /dev/null +++ b/Example/Photos/With Stores/Banner Feature/Banner.swift @@ -0,0 +1,15 @@ +// +// Banner.swift +// ViewStore +// +// Created by Kenneth Ackerson on 4/11/23. +// + +import Foundation + +/// Container for text intended to be displayed at the top of a screen. +struct Banner { + + /// The text to be displayed. + let title: String +} diff --git a/Example/Photos/With Stores/Banner Feature/BannerDataStore.swift b/Example/Photos/With Stores/Banner Feature/BannerDataStore.swift new file mode 100644 index 0000000..d220b5d --- /dev/null +++ b/Example/Photos/With Stores/Banner Feature/BannerDataStore.swift @@ -0,0 +1,83 @@ +// +// BannerDataStore.swift +// ViewStore +// +// Created by Kenneth Ackerson on 4/3/23. +// + +import Foundation +import Combine + +typealias BannerDataStoreType = Store + +/// A `Store` that is responsible for being the source of truth for a `Banner`. This includes updating locally and remotely. Not meant to be used to drive a `View`, but rather meant to be composed into other `Store`s. +final class BannerDataStore: Store { + + // MARK: - Store + + struct State { + + /// Initial state of the banner data store. + static let initial = State(banner: .init(title: "Banner"), networkState: .notStarted) + + /// The source of truth of the banner model object. + let banner: Banner + + /// Networking state of the request to upload a new banner model to the server. + let networkState: MockBannerNetworkStateController.NetworkState + } + + enum Action { + /// Changes the local copy of the banner model synchronously. + case updateBannerLocally(Banner) + + /// Sends the banner to the server and then updates the model locally if it was successful. + case uploadBanner(Banner) + + /// Clears the underlying networking state back to `notStarted`. + case clearNetworkingState + } + + @Published var state: State = BannerDataStore.State.initial + + var publishedState: AnyPublisher { + $state.eraseToAnyPublisher() + } + + // MARK: - BannerDataStore + + private let bannerSubject = PassthroughSubject() + private let network: MockBannerNetworkStateController = .init() + private var cancellables = Set() + + /// Creates a new `BannerDataStore` + init() { + + let networkPublisher = network.publisher.prepend(.notStarted) + let additionalActions = networkPublisher.compactMap { $0.banner }.map { Action.updateBannerLocally($0) } + + bannerSubject + .prepend(state.banner) + .combineLatest(network.publisher.prepend(.notStarted)) + .map { banner, networkState in + return State(banner: banner, networkState: networkState) + } + .assign(to: &$state) + + pipeActions(publisher: additionalActions, storeIn: &cancellables) + } + + // MARK: - Store + + func send(_ action: Action) { + switch action { + case .updateBannerLocally(let banner): + bannerSubject.send(banner) + case .uploadBanner(let banner): + network.upload(banner: banner) + case .clearNetworkingState: + network.reset() + } + } + +} diff --git a/Example/Photos/With Stores/Banner Feature/MockBannerNetworkStateController.swift b/Example/Photos/With Stores/Banner Feature/MockBannerNetworkStateController.swift new file mode 100644 index 0000000..f4388c0 --- /dev/null +++ b/Example/Photos/With Stores/Banner Feature/MockBannerNetworkStateController.swift @@ -0,0 +1,97 @@ +// +// MockBannerNetworkStateController.swift +// ViewStore +// +// Created by Kenneth Ackerson on 4/3/23. +// + +import Foundation +import Combine + +/// A really contrived fake interface similar to networking state controller for updating `Banner` models on a nonexistent server. +final class MockBannerNetworkStateController { + + /// Represents the state of a network request for a banner. + enum NetworkState { + + /// The network request has not started yet. + case notStarted + + /// The network request is currently in progress. + case inProgress + + /// The network request has finished and resulted in either success or failure. + /// - Parameter Result: A result type containing a `Banner` on success or a `NetworkError` on failure. + case finished(Result) + + /// The `Banner` object obtained from a successful network request, if available. + var banner: Banner? { + switch self { + case .inProgress, .notStarted: + return nil + case .finished(let result): + return try? result.get() + } + } + + /// The error obtained from a failed network request, if available. + var error: NetworkError? { + switch self { + case .notStarted, .inProgress: + return nil + case .finished(let result): + do { + _ = try result.get() + return nil + } catch let error as NetworkError { + return error + } catch { + assertionFailure("unhandled error") + return nil + } + } + } + + /// Possible errors that can occur when using this controller. + enum NetworkError: LocalizedError { + + /// A mocked error that is expected. + case intentionalFailure + + // MARK - LocalizedError + + var errorDescription: String? { + switch self { + case .intentionalFailure: + return "This is an expected error used for testing error handling." + } + } + } + } + + /// A publisher that sends updates of the `NetworkState`. + public var publisher: PassthroughSubject = .init() + + /// Uploads a `Banner` to a fake server. + /// - Parameter banner: The `Banner` to upload. + func upload(banner: Banner) { + self.publisher.send(.inProgress) + + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + + // Pick whether you would like to get a successful (`.finished(.success...`) state or any error for this "network request". + + //self.publisher.send(.finished(.success(banner))) + + self.publisher.send(.finished(.failure(.intentionalFailure))) + + } + + } + + /// Resets the current networking state to `notStarted`. + func reset() { + self.publisher.send(.notStarted) + } + +} diff --git a/Example/Photos/With Stores/Banner Feature/View Level/BannerUpdateView.swift b/Example/Photos/With Stores/Banner Feature/View Level/BannerUpdateView.swift new file mode 100644 index 0000000..699d0bc --- /dev/null +++ b/Example/Photos/With Stores/Banner Feature/View Level/BannerUpdateView.swift @@ -0,0 +1,73 @@ +// +// BannerUpdateView.swift +// ViewStore +// +// Created by Kenneth Ackerson on 4/3/23. +// + +import Foundation +import SwiftUI +import Combine + +/// A really simple view that allows you to type and upload a new Banner. +struct BannerUpdateView: View { + + @Environment(\.dismiss) private var dismiss + + @StateObject private var store: Store + + /// Creates a new `BannerUpdateView`. + /// - Parameter store: The `Store` that drives this view. + init(store: @autoclosure @escaping () -> Store) { + self._store = StateObject(wrappedValue: store()) + } + + var body: some View { + VStack { + + VStack { + Spacer() + + TextField("", text: store.workingTitle) + .multilineTextAlignment(.center) + .padding(10) + .font(.system(size: 36)) + + Spacer() + } + .padding(.horizontal, 30) + + Button { + store.send(.submit) + } label: { + Group { + if store.state.dismissable { + Text("Submit") + } else { + ProgressView() + } + } + .foregroundColor(.white) + .padding(.vertical, 20) + .padding(.horizontal, 40) + .background { + RoundedRectangle(cornerRadius: 10) + .foregroundColor(.blue) + } + } + .disabled(!store.state.dismissable) + .padding(.bottom, 10) + } + .onChange(of: store.state.success) { success in + if success { + dismiss() + } + } + .alert(isPresented: store.isErrorPresented, error: store.state.error) { _ in + + } message: { error in + Text("Error") + } + + } +} diff --git a/Example/Photos/With Stores/Banner Feature/View Level/BannerUpdateViewStore.swift b/Example/Photos/With Stores/Banner Feature/View Level/BannerUpdateViewStore.swift new file mode 100644 index 0000000..c02d243 --- /dev/null +++ b/Example/Photos/With Stores/Banner Feature/View Level/BannerUpdateViewStore.swift @@ -0,0 +1,121 @@ +// +// BannerUpdateViewStore.swift +// ViewStore +// +// Created by Kenneth Ackerson on 4/3/23. +// + +import Foundation +import Combine +import SwiftUI +import CasePaths + +typealias BannerUpdateViewStoreType = Store + +/// A `Store` that drives a view that can update a `Banner` through any `BannerDataStoreType`, and exposes view-specific state such as a working copy of the banner, the possible networking error, etc. +final class BannerUpdateViewStore: Store { + + // MARK: - Store + + /// Represents the state of the `BannerUpdateViewStore` + struct State { + /// Stores the state of the nested `BannerDataStore` + let bannerViewState: BannerDataStore.State + + /// A working copy of the banner being updated, to be uploaded if the `submit` action is sent. + let workingCopy: Banner + + /// Returns true if the network state is not started or finished, false if it's in progress + var dismissable: Bool { + switch bannerViewState.networkState { + case .notStarted, .finished: + return true + case .inProgress: + return false + } + } + + /// Returns true if the network state is finished and the result is successful, false otherwise + var success: Bool { + switch bannerViewState.networkState { + case .notStarted, .inProgress: + return false + case .finished(let result): + return (try? result.get()) != nil + } + } + + // Returns a `NetworkError` if there is an error in the network state when it's finished, otherwise returns nil + var error: MockBannerNetworkStateController.NetworkState.NetworkError? { + return bannerViewState.networkState.error + } + } + + enum Action { + /// Action to update the title of the banner with a given string + case updateTitle(String) + + /// Action to dismiss an error + case dismissError + + /// Action to submit the updated working copy banner to the network + case submit + } + + @Published var state: State + var publishedState: AnyPublisher { + return $state.eraseToAnyPublisher() + } + + // MARK: - BannerUpdateViewStore + + private let bannerDataStore: any BannerDataStoreType + + private let newTitlePublisher = PassthroughSubject() + + /// Creates a new `BannerUpdateViewStore`. + /// - Parameter bannerDataStore: The data `Store` responsible for updating the banner on the network and its source of truth in the application. + init(bannerDataStore: any BannerDataStoreType) { + self.bannerDataStore = bannerDataStore + + state = State(bannerViewState: bannerDataStore.state, workingCopy: bannerDataStore.state.banner) + + bannerDataStore + .publishedState + .combineLatest(newTitlePublisher.map(Banner.init).prepend(state.workingCopy)) + .map { bannerState, workingCopy in + State(bannerViewState: bannerState, workingCopy: workingCopy) + } + .assign(to: &$state) + } + + // MARK: - Store + + func send(_ action: Action) { + switch action { + case .updateTitle(let title): + newTitlePublisher.send(title) + case .submit: + bannerDataStore.send(.uploadBanner(state.workingCopy)) + case .dismissError: + bannerDataStore.send(.clearNetworkingState) + } + } + +} + +extension BannerUpdateViewStoreType { + /// Computed property that creates a binding for the working title + var workingTitle: Binding { + makeBinding(stateKeyPath: \.workingCopy.title, actionCasePath: /Action.updateTitle) + } + + /// Computed property that creates a binding for the error presentation state + var isErrorPresented: Binding { + .init(get: { + return self.state.error != nil + }, set: { _ in + self.send(.dismissError) + }) + } +} diff --git a/Example/Photos/With Stores/Banner Feature/View Level/BannerView.swift b/Example/Photos/With Stores/Banner Feature/View Level/BannerView.swift new file mode 100644 index 0000000..4aff17e --- /dev/null +++ b/Example/Photos/With Stores/Banner Feature/View Level/BannerView.swift @@ -0,0 +1,24 @@ +// +// BannerView.swift +// ViewStore +// +// Created by Kenneth Ackerson on 4/3/23. +// + +import Foundation +import SwiftUI + +/// Simple view to show a banner. +struct BannerView: View { + + /// The `Banner` to display. + let banner: Banner + + var body: some View { + HStack { + Spacer() + Text(banner.title) + Spacer() + } + } +} diff --git a/Example/Photos/PhotoList.swift b/Example/Photos/With Stores/PhotoList.swift similarity index 85% rename from Example/Photos/PhotoList.swift rename to Example/Photos/With Stores/PhotoList.swift index 187a419..f7088be 100644 --- a/Example/Photos/PhotoList.swift +++ b/Example/Photos/With Stores/PhotoList.swift @@ -32,6 +32,12 @@ struct PhotoList: View { .scaleEffect(x: 2, y: 2) case let .content(photos): List { + + BannerView(banner: store.state.banner) + .onTapGesture { + store.send(.showUpdateView(true)) + } + Section { ForEach(photos) { photo in HStack { @@ -51,6 +57,9 @@ struct PhotoList: View { .animation(.easeInOut, value: store.state.showsPhotoCount) } } + .sheet(isPresented: store.showUpdateView) { + BannerUpdateView(store: BannerUpdateViewStore(bannerDataStore: store.bannerDataStore)) + } case let .error(error): VStack { Image(systemName: "xmark.octagon") diff --git a/Example/Photos/PhotoListViewStore.swift b/Example/Photos/With Stores/PhotoListViewStore.swift similarity index 55% rename from Example/Photos/PhotoListViewStore.swift rename to Example/Photos/With Stores/PhotoListViewStore.swift index 7d929c4..3b5b1ae 100644 --- a/Example/Photos/PhotoListViewStore.swift +++ b/Example/Photos/With Stores/PhotoListViewStore.swift @@ -19,44 +19,99 @@ final class PhotoListViewStore: Store { // MARK: - Store struct State { + + /// Status defines the current status of the photo list view enum Status { + /// Error state with associated Error object case error(Error) + + /// Loading state while fetching photos case loading + + /// Content state with an array of Photo objects case content([Photo]) } - + + /// Default navigation title for the view fileprivate static let defaultNavigationTitle = LocalizedStringKey("Photos") + + /// Initial state of the photo list view store fileprivate static let initial = State() - + + /// Current status of the photo list view let status: Status + + /// Determines if the photo count should be displayed let showsPhotoCount: Bool + + /// Navigation title for the view let navigationTitle: LocalizedStringKey + + /// Search text entered by the user let searchText: String - init(status: PhotoListViewStore.State.Status = .loading, showsPhotoCount: Bool = false, navigationTitle: LocalizedStringKey = State.defaultNavigationTitle, searchText: String = "") { + /// State of the nested banner data store + let bannerState: BannerDataStore.State + + /// Determines whether to show a view that allows the user to enter new text for the banner + let showUpdateView: Bool + + /// Computed property to get the source of truth `banner` from `bannerState` + var banner: Banner { + return bannerState.banner + } + + /// Initializes a new State instance with provided or default values + /// - Parameters: + /// - status: The current status of the photo list view + /// - showsPhotoCount: Determines if the photo count should be displayed + /// - navigationTitle: Navigation title for the view + /// - searchText: Search text entered by the user + /// - bannerState: State of the banner data store + /// - showUpdateView: Determines if the update view should be shown + init(status: PhotoListViewStore.State.Status = .loading, + showsPhotoCount: Bool = false, + navigationTitle: LocalizedStringKey = State.defaultNavigationTitle, + searchText: String = "", + bannerState: BannerDataStore.State = .initial, + showUpdateView: Bool = false) { self.status = status self.showsPhotoCount = showsPhotoCount self.navigationTitle = navigationTitle self.searchText = searchText + self.bannerState = bannerState + self.showUpdateView = showUpdateView } } - + enum Action { + /// Toggle the display of photo count case toggleShowsPhotoCount(Bool) + + /// Perform search with given query string case search(String) + + /// Toggle the display of the update view + case showUpdateView(Bool) + + /// Nested banner action cases + case bannerAction(BannerDataStore.Action) } @Published private(set) var state: State = .initial var publishedState: AnyPublisher { - return $state.eraseToAnyPublisher() + $state.eraseToAnyPublisher() } // MARK: - PhotoListViewStore private let provider: Provider private let showsPhotosCountPublisher = PassthroughSubject() + private let showUpdateViewPublisher = PassthroughSubject() private let searchTextPublisher = PassthroughSubject() + + private let bannerDataStore = BannerDataStore() /// Creates a new `PhotoListViewStore` /// - Parameters: @@ -68,17 +123,18 @@ final class PhotoListViewStore: Store { let photoPublisher = provider.providePhotos().prepend([]) let searchTextUIPublisher = self.searchTextPublisher.prepend(State.initial.searchText) let searchTextPublisher = searchTextUIPublisher.throttle(for: 1, scheduler: scheduler, latest: true) + photoPublisher - .combineLatest(showsPhotosCountPublisher, searchTextPublisher, searchTextUIPublisher) - .map { (result: Result<[Photo], ProviderError>, showsPhotosCount: Bool, searchText: String, searchTextUI: String) in + .combineLatest(showsPhotosCountPublisher, searchTextPublisher, searchTextUIPublisher, bannerDataStore.$state, showUpdateViewPublisher.prepend(false)) + .map { (result: Result<[Photo], ProviderError>, showsPhotosCount: Bool, searchText: String, searchTextUI: String, bannerViewState, showUpdateView) in switch result { case let .success(photos): let filteredPhotos = photos.filter(searchText: searchText) let navigationTitle = showsPhotosCount ? LocalizedStringKey("Photos \(filteredPhotos.count)") : State.defaultNavigationTitle - return State(status: .content(filteredPhotos), showsPhotoCount: showsPhotosCount, navigationTitle: navigationTitle, searchText: searchTextUI) + return State(status: .content(filteredPhotos), showsPhotoCount: showsPhotosCount, navigationTitle: navigationTitle, searchText: searchTextUI, bannerState: bannerViewState, showUpdateView: showUpdateView) case let .failure(error): - return State(status: .error(error), showsPhotoCount: false, navigationTitle: State.defaultNavigationTitle, searchText: searchTextUI) + return State(status: .error(error), showsPhotoCount: false, navigationTitle: State.defaultNavigationTitle, searchText: searchTextUI, bannerState: bannerViewState, showUpdateView: showUpdateView) } } .receive(on: scheduler) @@ -93,11 +149,27 @@ final class PhotoListViewStore: Store { showsPhotosCountPublisher.send(showsPhotoCount) case let .search(searchText): searchTextPublisher.send(searchText) + case let .bannerAction(action): + bannerDataStore.send(action) + case let .showUpdateView(showUpdateView): + showUpdateViewPublisher.send(showUpdateView) } } } extension PhotoListViewStoreType { + + /// Computed property that provides a scoped `BannerDataStoreType` instance + var bannerDataStore: any BannerDataStoreType { + return scoped(stateKeyPath: \.bannerState, actionCasePath: /Action.bannerAction) + } + + /// Computed property that creates a binding for the `showUpdateView` state + var showUpdateView: Binding { + makeBinding(stateKeyPath: \.showUpdateView, actionCasePath: /PhotoListViewStore.Action.showUpdateView) + } + + /// Computed property that creates a binding for the `showsPhotoCount` state var showsPhotoCount: Binding { // // return Binding { @@ -110,6 +182,7 @@ extension PhotoListViewStoreType { makeBinding(stateKeyPath: \.showsPhotoCount, actionCasePath: /Action.toggleShowsPhotoCount) } + /// Computed property that creates a binding for the `searchText` state var searchText: Binding { makeBinding(stateKeyPath: \.searchText, actionCasePath: /Action.search) } diff --git a/Package.swift b/Package.swift index 3907f76..7af948b 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,6 @@ let package = Package( targets: [ .target(name: packageName, dependencies: [ .product(name: "CasePaths", package: "swift-case-paths") - ]) + ], path: "Sources/Store") ] ) diff --git a/Sources/Store/Store+Helpers.swift b/Sources/Store/Store+Helpers.swift index b15699c..e3e829c 100644 --- a/Sources/Store/Store+Helpers.swift +++ b/Sources/Store/Store+Helpers.swift @@ -8,9 +8,9 @@ import Foundation import Combine -extension Store { - - /// Takes a publisher of actions and executes them as they come in +public extension Store { + + /// Takes a publisher of actions and executes them as they come in. /// - Parameters: /// - publisher: The publisher of actions to execute as they come in. /// - cancellables: The set of cancellables to store into. diff --git a/ViewStore.xcodeproj/project.pbxproj b/ViewStore.xcodeproj/project.pbxproj index f50f04f..3e48cf7 100644 --- a/ViewStore.xcodeproj/project.pbxproj +++ b/ViewStore.xcodeproj/project.pbxproj @@ -22,14 +22,20 @@ 3A601C4F29E5CCF3008D0BB3 /* Store+BindingAdditions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C4E29E5CCF3008D0BB3 /* Store+BindingAdditions.swift */; }; 3A601C5129E5CD0B008D0BB3 /* ScopedStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C5029E5CD0B008D0BB3 /* ScopedStore.swift */; }; 3A601C5329E5CD11008D0BB3 /* Store+Helpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C5229E5CD11008D0BB3 /* Store+Helpers.swift */; }; + 3A601C5D29E5DA86008D0BB3 /* BannerDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C5729E5DA86008D0BB3 /* BannerDataStore.swift */; }; + 3A601C5E29E5DA86008D0BB3 /* BannerUpdateViewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C5829E5DA86008D0BB3 /* BannerUpdateViewStore.swift */; }; + 3A601C5F29E5DA86008D0BB3 /* BannerUpdateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C5929E5DA86008D0BB3 /* BannerUpdateView.swift */; }; + 3A601C6029E5DA86008D0BB3 /* PhotoList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C5A29E5DA86008D0BB3 /* PhotoList.swift */; }; + 3A601C6129E5DA86008D0BB3 /* BannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C5B29E5DA86008D0BB3 /* BannerView.swift */; }; + 3A601C6229E5DA86008D0BB3 /* PhotoListViewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C5C29E5DA86008D0BB3 /* PhotoListViewStore.swift */; }; + 3A601C6329E5DB55008D0BB3 /* MockBannerNetworkStateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A214A4329DB52E7001C9832 /* MockBannerNetworkStateController.swift */; }; + 3A601C6629E5DE22008D0BB3 /* Banner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A601C6529E5DE22008D0BB3 /* Banner.swift */; }; F2B5F5B827C7EC0C00FD7831 /* Example.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B5F5B727C7EC0C00FD7831 /* Example.swift */; }; F2B5F5BC27C7EC0E00FD7831 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F2B5F5BB27C7EC0E00FD7831 /* Assets.xcassets */; }; F2B5F5BF27C7EC0E00FD7831 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = F2B5F5BE27C7EC0E00FD7831 /* Preview Assets.xcassets */; }; F2B5F5C727C7EC5000FD7831 /* Provider in Frameworks */ = {isa = PBXBuildFile; productRef = F2B5F5C627C7EC5000FD7831 /* Provider */; }; F2B5F5CE27C7FA0F00FD7831 /* APIRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B5F5CD27C7FA0F00FD7831 /* APIRequest.swift */; }; - F2B5F5D427C7FB0600FD7831 /* PhotoList.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B5F5D127C7FB0600FD7831 /* PhotoList.swift */; }; F2B5F5D527C7FB0600FD7831 /* Photo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B5F5D227C7FB0600FD7831 /* Photo.swift */; }; - F2B5F5D627C7FB0600FD7831 /* PhotoListViewStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = F2B5F5D327C7FB0600FD7831 /* PhotoListViewStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -51,6 +57,7 @@ 0B3201F528B3BF6B00AF8884 /* MainQueueScheduler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainQueueScheduler.swift; sourceTree = ""; }; 0B77E915287F23C400BC3595 /* Array+Filtering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Filtering.swift"; sourceTree = ""; }; 0B92D48228577B7700FF1BDF /* PhotoListOriginal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoListOriginal.swift; sourceTree = ""; }; + 3A214A4329DB52E7001C9832 /* MockBannerNetworkStateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBannerNetworkStateController.swift; sourceTree = ""; }; 3A31E610287F47D700955C37 /* ViewStoreTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ViewStoreTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3A31E612287F47D800955C37 /* StoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StoreTests.swift; sourceTree = ""; }; 3A601C4A29E5CCE5008D0BB3 /* Store.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Store.swift; sourceTree = ""; }; @@ -58,14 +65,19 @@ 3A601C4E29E5CCF3008D0BB3 /* Store+BindingAdditions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Store+BindingAdditions.swift"; sourceTree = ""; }; 3A601C5029E5CD0B008D0BB3 /* ScopedStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScopedStore.swift; sourceTree = ""; }; 3A601C5229E5CD11008D0BB3 /* Store+Helpers.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Store+Helpers.swift"; sourceTree = ""; }; + 3A601C5729E5DA86008D0BB3 /* BannerDataStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BannerDataStore.swift; sourceTree = ""; }; + 3A601C5829E5DA86008D0BB3 /* BannerUpdateViewStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BannerUpdateViewStore.swift; sourceTree = ""; }; + 3A601C5929E5DA86008D0BB3 /* BannerUpdateView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BannerUpdateView.swift; sourceTree = ""; }; + 3A601C5A29E5DA86008D0BB3 /* PhotoList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoList.swift; sourceTree = ""; }; + 3A601C5B29E5DA86008D0BB3 /* BannerView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BannerView.swift; sourceTree = ""; }; + 3A601C5C29E5DA86008D0BB3 /* PhotoListViewStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoListViewStore.swift; sourceTree = ""; }; + 3A601C6529E5DE22008D0BB3 /* Banner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Banner.swift; sourceTree = ""; }; F2B5F5B427C7EC0C00FD7831 /* ViewStore.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ViewStore.app; sourceTree = BUILT_PRODUCTS_DIR; }; F2B5F5B727C7EC0C00FD7831 /* Example.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Example.swift; sourceTree = ""; }; F2B5F5BB27C7EC0E00FD7831 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; F2B5F5BE27C7EC0E00FD7831 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; F2B5F5CD27C7FA0F00FD7831 /* APIRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIRequest.swift; sourceTree = ""; }; - F2B5F5D127C7FB0600FD7831 /* PhotoList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoList.swift; sourceTree = ""; }; F2B5F5D227C7FB0600FD7831 /* Photo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Photo.swift; sourceTree = ""; }; - F2B5F5D327C7FB0600FD7831 /* PhotoListViewStore.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PhotoListViewStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -125,6 +137,52 @@ name = Frameworks; sourceTree = ""; }; + 3A601C5429E5D9C3008D0BB3 /* Recovered References */ = { + isa = PBXGroup; + children = ( + ); + name = "Recovered References"; + sourceTree = ""; + }; + 3A601C5629E5DA86008D0BB3 /* With Stores */ = { + isa = PBXGroup; + children = ( + 3A601C6729E5DE46008D0BB3 /* Banner Feature */, + 3A601C5A29E5DA86008D0BB3 /* PhotoList.swift */, + 3A601C5C29E5DA86008D0BB3 /* PhotoListViewStore.swift */, + ); + path = "With Stores"; + sourceTree = ""; + }; + 3A601C6429E5DCBC008D0BB3 /* Original */ = { + isa = PBXGroup; + children = ( + 0B92D48228577B7700FF1BDF /* PhotoListOriginal.swift */, + ); + path = Original; + sourceTree = ""; + }; + 3A601C6729E5DE46008D0BB3 /* Banner Feature */ = { + isa = PBXGroup; + children = ( + 3A601C6829E5DE62008D0BB3 /* View Level */, + 3A601C6529E5DE22008D0BB3 /* Banner.swift */, + 3A601C5729E5DA86008D0BB3 /* BannerDataStore.swift */, + 3A214A4329DB52E7001C9832 /* MockBannerNetworkStateController.swift */, + ); + path = "Banner Feature"; + sourceTree = ""; + }; + 3A601C6829E5DE62008D0BB3 /* View Level */ = { + isa = PBXGroup; + children = ( + 3A601C5929E5DA86008D0BB3 /* BannerUpdateView.swift */, + 3A601C5829E5DA86008D0BB3 /* BannerUpdateViewStore.swift */, + 3A601C5B29E5DA86008D0BB3 /* BannerView.swift */, + ); + path = "View Level"; + sourceTree = ""; + }; F2B5F5AB27C7EC0C00FD7831 = { isa = PBXGroup; children = ( @@ -133,6 +191,7 @@ 3A31E611287F47D800955C37 /* ExampleTests */, F2B5F5B527C7EC0C00FD7831 /* Products */, 3A31E61C287F599C00955C37 /* Frameworks */, + 3A601C5429E5D9C3008D0BB3 /* Recovered References */, ); sourceTree = ""; }; @@ -174,9 +233,8 @@ isa = PBXGroup; children = ( F2B5F5D227C7FB0600FD7831 /* Photo.swift */, - F2B5F5D127C7FB0600FD7831 /* PhotoList.swift */, - 0B92D48228577B7700FF1BDF /* PhotoListOriginal.swift */, - F2B5F5D327C7FB0600FD7831 /* PhotoListViewStore.swift */, + 3A601C6429E5DCBC008D0BB3 /* Original */, + 3A601C5629E5DA86008D0BB3 /* With Stores */, ); path = Photos; sourceTree = ""; @@ -308,21 +366,27 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 3A601C5F29E5DA86008D0BB3 /* BannerUpdateView.swift in Sources */, 0B77E916287F23C400BC3595 /* Array+Filtering.swift in Sources */, F2B5F5D527C7FB0600FD7831 /* Photo.swift in Sources */, 0B3201F728B3BF6B00AF8884 /* Publisher+CombineLatest.swift in Sources */, - F2B5F5D627C7FB0600FD7831 /* PhotoListViewStore.swift in Sources */, 3A601C5129E5CD0B008D0BB3 /* ScopedStore.swift in Sources */, 3A601C4B29E5CCE5008D0BB3 /* Store.swift in Sources */, - F2B5F5D427C7FB0600FD7831 /* PhotoList.swift in Sources */, + 3A601C6129E5DA86008D0BB3 /* BannerView.swift in Sources */, + 3A601C5E29E5DA86008D0BB3 /* BannerUpdateViewStore.swift in Sources */, + 3A601C6029E5DA86008D0BB3 /* PhotoList.swift in Sources */, 3A601C4F29E5CCF3008D0BB3 /* Store+BindingAdditions.swift in Sources */, + 3A601C5D29E5DA86008D0BB3 /* BannerDataStore.swift in Sources */, + 3A601C6629E5DE22008D0BB3 /* Banner.swift in Sources */, 3A601C5329E5CD11008D0BB3 /* Store+Helpers.swift in Sources */, F2B5F5CE27C7FA0F00FD7831 /* APIRequest.swift in Sources */, 0B92D48328577B7700FF1BDF /* PhotoListOriginal.swift in Sources */, + 3A601C6329E5DB55008D0BB3 /* MockBannerNetworkStateController.swift in Sources */, 0B3201F928B3BF6B00AF8884 /* MainQueueScheduler.swift in Sources */, 0B0FC066287C6E3D00207496 /* MockItemProvider.swift in Sources */, F2B5F5B827C7EC0C00FD7831 /* Example.swift in Sources */, 3A601C4D29E5CCEC008D0BB3 /* MockStore.swift in Sources */, + 3A601C6229E5DA86008D0BB3 /* PhotoListViewStore.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; };