Repository: pitt500/OnlineStoreTCA Branch: main Commit: 89a5e4eaab96 Files: 37 Total size: 139.6 KB Directory structure: gitextract_gp_koxcg/ ├── .gitignore ├── OnlineStoreTCA/ │ ├── Assets.xcassets/ │ │ ├── AccentColor.colorset/ │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset/ │ │ │ └── Contents.json │ │ ├── Contents.json │ │ ├── bag.imageset/ │ │ │ └── Contents.json │ │ ├── jacket.imageset/ │ │ │ └── Contents.json │ │ └── tshirt.imageset/ │ │ └── Contents.json │ ├── CartList/ │ │ ├── Cart/ │ │ │ ├── CartCell.swift │ │ │ ├── CartItem.swift │ │ │ └── CartItemDomain.swift │ │ ├── CartListDomain.swift │ │ ├── CartListView.swift │ │ └── Test/ │ │ └── CartListDomainTest.swift │ ├── Network/ │ │ ├── APIClient.swift │ │ └── DataLoadingStatus.swift │ ├── Preview Content/ │ │ └── Preview Assets.xcassets/ │ │ └── Contents.json │ ├── Products/ │ │ ├── AddToCart/ │ │ │ ├── AddToCartButton.swift │ │ │ ├── AddToCartDomain.swift │ │ │ ├── PlusMinusButton.swift │ │ │ └── Test/ │ │ │ └── AddToCartDomainTest.swift │ │ ├── Product/ │ │ │ ├── Product.swift │ │ │ ├── ProductCell.swift │ │ │ ├── ProductDomain.swift │ │ │ └── Test/ │ │ │ └── ProductDomainTest.swift │ │ └── ProductList/ │ │ ├── ErrorView.swift │ │ ├── ProductListDomain.swift │ │ ├── ProductListView.swift │ │ └── Test/ │ │ └── ProductListDomainTest.swift │ ├── Profile/ │ │ ├── ProfileDomain.swift │ │ ├── ProfileView.swift │ │ └── UserProfile.swift │ └── Root/ │ ├── OnlineStoreTCAApp.swift │ ├── RootDomain.swift │ └── RootView.swift ├── OnlineStoreTCA.xcodeproj/ │ └── project.pbxproj ├── OnlineStoreTCATests/ │ └── OnlineStoreTCATests.swift └── README.md ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ # Created by https://www.toptal.com/developers/gitignore/api/xcode,swift,macos # Edit at https://www.toptal.com/developers/gitignore?templates=xcode,swift,macos ### macOS ### # General .DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### macOS Patch ### # iCloud generated files *.icloud ### Swift ### # Xcode # # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore ## User settings xcuserdata/ ## compatibility with Xcode 8 and earlier (ignoring not required starting Xcode 9) *.xcscmblueprint *.xccheckout ## compatibility with Xcode 3 and earlier (ignoring not required starting Xcode 4) build/ DerivedData/ *.moved-aside *.pbxuser !default.pbxuser *.mode1v3 !default.mode1v3 *.mode2v3 !default.mode2v3 *.perspectivev3 !default.perspectivev3 ## Obj-C/Swift specific *.hmap ## App packaging *.ipa *.dSYM.zip *.dSYM ## Playgrounds timeline.xctimeline playground.xcworkspace # Swift Package Manager # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. # Packages/ # Package.pins # Package.resolved # *.xcodeproj # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata # hence it is not needed unless you have added a package configuration file to your project # .swiftpm .build/ # CocoaPods # We recommend against adding the Pods directory to your .gitignore. However # you should judge for yourself, the pros and cons are mentioned at: # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control # Pods/ # Add this line if you want to avoid checking in source code from the Xcode workspace # *.xcworkspace # Carthage # Add this line if you want to avoid checking in source code from Carthage dependencies. # Carthage/Checkouts Carthage/Build/ # Accio dependency management Dependencies/ .accio/ # fastlane # It is recommended to not store the screenshots in the git repo. # Instead, use fastlane to re-generate the screenshots whenever they are needed. # For more information about the recommended setup visit: # https://docs.fastlane.tools/best-practices/source-control/#source-control fastlane/report.xml fastlane/Preview.html fastlane/screenshots/**/*.png fastlane/test_output # Code Injection # After new code Injection tools there's a generated folder /iOSInjectionProject # https://github.com/johnno1962/injectionforxcode iOSInjectionProject/ ### Xcode ### ## Xcode 8 and earlier ### Xcode Patch ### *.xcodeproj/* !*.xcodeproj/project.pbxproj !*.xcodeproj/xcshareddata/ !*.xcworkspace/contents.xcworkspacedata /*.gcno **/xcshareddata/WorkspaceSettings.xcsettings # End of https://www.toptal.com/developers/gitignore/api/xcode,swift,macos ================================================ FILE: OnlineStoreTCA/Assets.xcassets/AccentColor.colorset/Contents.json ================================================ { "colors" : [ { "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: OnlineStoreTCA/Assets.xcassets/AppIcon.appiconset/Contents.json ================================================ { "images" : [ { "idiom" : "iphone", "scale" : "2x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "3x", "size" : "20x20" }, { "idiom" : "iphone", "scale" : "2x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "3x", "size" : "29x29" }, { "idiom" : "iphone", "scale" : "2x", "size" : "40x40" }, { "idiom" : "iphone", "scale" : "3x", "size" : "40x40" }, { "idiom" : "iphone", "scale" : "2x", "size" : "60x60" }, { "idiom" : "iphone", "scale" : "3x", "size" : "60x60" }, { "idiom" : "ipad", "scale" : "1x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "2x", "size" : "20x20" }, { "idiom" : "ipad", "scale" : "1x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "2x", "size" : "29x29" }, { "idiom" : "ipad", "scale" : "1x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "2x", "size" : "40x40" }, { "idiom" : "ipad", "scale" : "1x", "size" : "76x76" }, { "idiom" : "ipad", "scale" : "2x", "size" : "76x76" }, { "idiom" : "ipad", "scale" : "2x", "size" : "83.5x83.5" }, { "idiom" : "ios-marketing", "scale" : "1x", "size" : "1024x1024" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: OnlineStoreTCA/Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: OnlineStoreTCA/Assets.xcassets/bag.imageset/Contents.json ================================================ { "images" : [ { "filename" : "bag.jpg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: OnlineStoreTCA/Assets.xcassets/jacket.imageset/Contents.json ================================================ { "images" : [ { "filename" : "jacket.jpg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: OnlineStoreTCA/Assets.xcassets/tshirt.imageset/Contents.json ================================================ { "images" : [ { "filename" : "tshirt.jpg", "idiom" : "universal", "scale" : "1x" }, { "idiom" : "universal", "scale" : "2x" }, { "idiom" : "universal", "scale" : "3x" } ], "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: OnlineStoreTCA/CartList/Cart/CartCell.swift ================================================ // // CartCell.swift // OnlineStoreTCA // // Created by Pedro Rojas on 22/08/22. // import SwiftUI import ComposableArchitecture struct CartCell: View { let store: StoreOf var body: some View { WithPerceptionTracking { VStack { HStack { AsyncImage( url: URL( string: store.cartItem.product.imageString ) ) { $0 .resizable() .aspectRatio(contentMode: .fit) .frame(width: 100, height: 100) } placeholder: { ProgressView() .frame(width: 100, height: 100) } VStack(alignment: .leading) { Text(store.cartItem.product.title) .lineLimit(3) .minimumScaleFactor(0.5) HStack { Text("$\(store.cartItem.product.price.description)") .font(.custom("AmericanTypewriter", size: 25)) .fontWeight(.bold) } } } ZStack { Group { Text("Quantity: ") + Text("\(store.cartItem.quantity)") .fontWeight(.bold) } .font(.custom("AmericanTypewriter", size: 25)) HStack { Spacer() Button { store.send( .deleteCartItem( product: store.cartItem.product ) ) } label: { Image(systemName: "trash.fill") .foregroundColor(.red) .padding() } } } } .font(.custom("AmericanTypewriter", size: 20)) .padding([.bottom, .top], 10) } } } struct CartCell_Previews: PreviewProvider { static var previews: some View { CartCell( store: Store( initialState: CartItemDomain.State( id: UUID(), cartItem: CartItem.sample.first! ), reducer: { CartItemDomain() } ) ) .previewLayout(.fixed(width: 300, height: 300)) } } ================================================ FILE: OnlineStoreTCA/CartList/Cart/CartItem.swift ================================================ // // CartItem.swift // OnlineStoreTCA // // Created by Pedro Rojas on 18/08/22. // import Foundation struct CartItem: Equatable { let product: Product let quantity: Int } extension CartItem: Encodable { private enum CartItemsKey: String, CodingKey { case productId case quantity } func encode(to encoder: Encoder) throws { var container = encoder.container(keyedBy: CartItemsKey.self) try container.encode(product.id, forKey: .productId) try container.encode(quantity, forKey: .quantity) } } extension CartItem { static var sample: [CartItem] { [ .init( product: Product.sample[0], quantity: 3 ), .init( product: Product.sample[1], quantity: 1 ), .init( product: Product.sample[2], quantity: 1 ), ] } } ================================================ FILE: OnlineStoreTCA/CartList/Cart/CartItemDomain.swift ================================================ // // CartItemDomain.swift // OnlineStoreTCA // // Created by Pedro Rojas on 22/08/22. // import Foundation import ComposableArchitecture @Reducer struct CartItemDomain { @ObservableState struct State: Equatable, Identifiable { let id: UUID let cartItem: CartItem } enum Action: Equatable { case deleteCartItem(product: Product) } func reduce(into state: inout State, action: Action) -> Effect { switch action { case .deleteCartItem: return .none } } } ================================================ FILE: OnlineStoreTCA/CartList/CartListDomain.swift ================================================ // // CartListDomain.swift // OnlineStoreTCA // // Created by Pedro Rojas on 18/08/22. // import Foundation import ComposableArchitecture @Reducer struct CartListDomain { @ObservableState struct State: Equatable { @Presents var alert: AlertState? var dataLoadingStatus = DataLoadingStatus.notStarted var cartItems: IdentifiedArrayOf = [] var totalPrice: Double = 0.0 var isPayButtonDisable = false var totalPriceString: String { let roundedValue = round(totalPrice * 100) / 100.0 return "$\(roundedValue)" } var isRequestInProcess: Bool { dataLoadingStatus == .loading } } enum Action: Equatable { case alert(PresentationAction) case didPressCloseButton case cartItem(IdentifiedActionOf) case getTotalPrice case didPressPayButton case didReceivePurchaseResponse(TaskResult) @CasePathable enum Alert: Equatable { case didConfirmPurchase case didCancelConfirmation case dismissSuccessAlert case dismissErrorAlert } } @Dependency(\.apiClient.sendOrder) var sendOrder private func verifyPayButtonVisibility( state: inout State ) -> Effect { state.isPayButtonDisable = state.totalPrice == 0.0 return .none } var body: some ReducerOf { Reduce { state, action in switch action { case let .alert(.presented(alertAction)): switch alertAction { case .didConfirmPurchase: state.dataLoadingStatus = .loading let items = state.cartItems.map { $0.cartItem } return .run { send in await send( .didReceivePurchaseResponse( TaskResult{ try await sendOrder(items) } ) ) } case .didCancelConfirmation: state.alert = nil return .none case .dismissSuccessAlert: state.alert = nil return .none case .dismissErrorAlert: state.alert = nil return .none } case .alert: return .none case .didPressCloseButton: return .none case let .cartItem(.element(id: id, action: action)): switch action { case .deleteCartItem: state.cartItems.remove(id: id) return.send(.getTotalPrice) } case .getTotalPrice: let items = state.cartItems.map { $0.cartItem } state.totalPrice = items.reduce(0.0, { $0 + ($1.product.price * Double($1.quantity)) }) return verifyPayButtonVisibility(state: &state) case .didPressPayButton: state.alert = .confirmationAlert(totalPriceString: state.totalPriceString) return .none case .didReceivePurchaseResponse(.success(let message)): state.dataLoadingStatus = .success state.alert = .successAlert print("Success: ", message) return .none case .didReceivePurchaseResponse(.failure(let error)): state.dataLoadingStatus = .error state.alert = .errorAlert print("Error sending your order:", error.localizedDescription) return .none } } .ifLet(\.$alert, action: \.alert) .forEach(\.cartItems, action: \.cartItem) { CartItemDomain() } } } extension AlertState where Action == CartListDomain.Action.Alert { static func confirmationAlert(totalPriceString: String) -> AlertState { AlertState { TextState("Confirm your purchase") } actions: { ButtonState(action: .didConfirmPurchase, label: { TextState("Pay \(totalPriceString)") }) ButtonState(role: .cancel, action: .didCancelConfirmation, label: { TextState("Cancel") }) } message: { TextState("Do you want to proceed with your purchase of \(totalPriceString)?") } } static var successAlert: AlertState { AlertState { TextState("Thank you!") } actions: { ButtonState(action: .dismissSuccessAlert, label: { TextState("Done") }) } message: { TextState("Your order is in process.") } } static var errorAlert: AlertState { AlertState { TextState("Oops!") } actions: { ButtonState(action: .dismissErrorAlert, label: { TextState("Done") }) } message: { TextState("Unable to send order, try again later.") } } } ================================================ FILE: OnlineStoreTCA/CartList/CartListView.swift ================================================ // // CartListView.swift // OnlineStoreTCA // // Created by Pedro Rojas on 18/08/22. // import SwiftUI import ComposableArchitecture struct CartListView: View { let store: StoreOf var body: some View { WithPerceptionTracking { ZStack { NavigationStack { Group { if store.cartItems.isEmpty { Text("Oops, your cart is empty! \n") .font(.custom("AmericanTypewriter", size: 25)) } else { List { ForEach( store.scope(state: \.cartItems, action: \.cartItem), id: \.id ) { store in CartCell(store: store) } } .safeAreaInset(edge: .bottom) { Button { store.send(.didPressPayButton) } label: { HStack(alignment: .center) { Spacer() Text("Pay \(store.totalPriceString)") .font(.custom("AmericanTypewriter", size: 30)) .foregroundColor(.white) Spacer() } } .frame(maxWidth: .infinity, minHeight: 60) .background( store.isPayButtonDisable ? .gray : .blue ) .cornerRadius(10) .padding() .disabled(store.isPayButtonDisable) } .alert( store: store.scope( state: \.$alert, action: \.alert ) ) } } .navigationTitle("Cart") .toolbar { ToolbarItem(placement: .navigationBarLeading) { Button { store.send(.didPressCloseButton) } label: { Text("Close") } } } .onAppear { store.send(.getTotalPrice) } } if store.isRequestInProcess { Color.black.opacity(0.2) .ignoresSafeArea() ProgressView() } } } } } struct CartListView_Previews: PreviewProvider { static var previews: some View { CartListView( store: Store( initialState: CartListDomain.State( cartItems: IdentifiedArrayOf( uniqueElements: CartItem.sample .map { CartItemDomain.State( id: UUID(), cartItem: $0 ) } ) ), reducer: { CartListDomain() }, withDependencies: { $0.apiClient.sendOrder = { _ in "OK" } } ) ) } } ================================================ FILE: OnlineStoreTCA/CartList/Test/CartListDomainTest.swift ================================================ // // CartListDomainTest.swift // OnlineStoreTCATests // // Created by Pedro Rojas on 30/08/22. // import ComposableArchitecture import XCTest @testable import OnlineStoreTCA @MainActor class CartListDomainTest: XCTestCase { func testRemoveItemFromCart() async { let cartItemId1 = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! let cartItemId2 = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! let itemQuantity = 2 let cartItems: IdentifiedArrayOf = [ .init( id: cartItemId1, cartItem: CartItem.init( product: Product.sample[0], quantity: itemQuantity ) ), .init( id: cartItemId2, cartItem: CartItem.init( product: Product.sample[1], quantity: itemQuantity ) ), ] let store = TestStore( initialState: CartListDomain.State(cartItems: cartItems), reducer: { CartListDomain() } ) await store.send(\.cartItem[id: cartItemId1].deleteCartItem, Product.sample[0]) { $0.cartItems = [ .init( id: cartItemId2, cartItem: CartItem.init( product: Product.sample[1], quantity: itemQuantity ) ) ] } let expectedPrice = Product.sample[1].price * Double(itemQuantity) await store.receive(\.getTotalPrice) { $0.totalPrice = expectedPrice } } func testRemoveAllItemsFromCart() async { let cartItemId1 = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! let cartItemId2 = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! let itemQuantity = 2 let cartItems: IdentifiedArrayOf = [ .init( id: cartItemId1, cartItem: CartItem.init( product: Product.sample[0], quantity: itemQuantity ) ), .init( id: cartItemId2, cartItem: CartItem.init( product: Product.sample[1], quantity: itemQuantity ) ), ] let store = TestStore( initialState: CartListDomain.State(cartItems: cartItems), reducer: { CartListDomain() } ) await store.send(\.cartItem[id: cartItemId1].deleteCartItem, Product.sample[0]) { $0.cartItems = [ .init( id: cartItemId2, cartItem: CartItem.init( product: Product.sample[1], quantity: itemQuantity ) ) ] } let expectedPrice = Product.sample[1].price * Double(itemQuantity) await store.receive(\.getTotalPrice) { $0.totalPrice = expectedPrice } await store.send(\.cartItem[id: cartItemId2].deleteCartItem, Product.sample[1]) { $0.cartItems = [] } await store.receive(\.getTotalPrice) { $0.totalPrice = 0 $0.isPayButtonDisable = true } } func testSendOrderSuccessfully() async { let cartItemId1 = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! let cartItemId2 = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! let itemQuantity = 2 let cartItems: IdentifiedArrayOf = [ .init( id: cartItemId1, cartItem: CartItem.init( product: Product.sample[0], quantity: itemQuantity ) ), .init( id: cartItemId2, cartItem: CartItem.init( product: Product.sample[1], quantity: itemQuantity ) ), ] let store = TestStore( initialState: CartListDomain.State(cartItems: cartItems), reducer: { CartListDomain() } ) { $0.apiClient.sendOrder = { _ in "Success" } } await store.send(\.didPressPayButton) { $0.alert = .confirmationAlert(totalPriceString: "$0.0") } await store.send(\.alert.didConfirmPurchase) { $0.alert = nil $0.dataLoadingStatus = .loading } await store.receive(\.didReceivePurchaseResponse, .success("Success")) { $0.dataLoadingStatus = .success $0.alert = .successAlert } } func testSendOrderWithError() async { let cartItemId1 = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! let cartItemId2 = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! let itemQuantity = 2 let cartItems: IdentifiedArrayOf = [ .init( id: cartItemId1, cartItem: CartItem.init( product: Product.sample[0], quantity: itemQuantity ) ), .init( id: cartItemId2, cartItem: CartItem.init( product: Product.sample[1], quantity: itemQuantity ) ), ] let store = TestStore( initialState: CartListDomain.State(cartItems: cartItems), reducer: { CartListDomain() } ) { $0.apiClient.sendOrder = { _ in throw APIClient.Failure() } } await store.send(\.didPressPayButton) { $0.alert = .confirmationAlert(totalPriceString: "$0.0") } await store.send(\.alert.didConfirmPurchase) { $0.dataLoadingStatus = .loading $0.alert = nil } await store.receive(\.didReceivePurchaseResponse, .failure(APIClient.Failure())) { $0.dataLoadingStatus = .error $0.alert = .errorAlert } } } ================================================ FILE: OnlineStoreTCA/Network/APIClient.swift ================================================ // // APIClient.swift // OnlineStoreTCA // // Created by Pedro Rojas on 23/08/22. // import Foundation import ComposableArchitecture import DependenciesMacros extension DependencyValues { var apiClient: APIClient { get { self[APIClient.self] } set { self[APIClient.self] = newValue } } } @DependencyClient struct APIClient { var fetchProducts: @Sendable () async throws -> [Product] var sendOrder: @Sendable ([CartItem]) async throws -> String var fetchUserProfile: @Sendable () async throws -> UserProfile struct Failure: Error, Equatable {} } extension APIClient: TestDependencyKey { static let testValue = Self() } // This is the "live" fact dependency that reaches into the outside world to fetch the data from network. // Typically this live implementation of the dependency would live in its own module so that the // main feature doesn't need to compile it. extension APIClient: DependencyKey { static let liveValue = Self( fetchProducts: { let (data, _) = try await URLSession.shared .data(from: URL(string: "https://fakestoreapi.com/products")!) let products = try JSONDecoder().decode([Product].self, from: data) return products }, sendOrder: { cartItems in let payload = try JSONEncoder().encode(cartItems) var urlRequest = URLRequest(url: URL(string: "https://fakestoreapi.com/carts")!) urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") urlRequest.httpMethod = "POST" let (data, response) = try await URLSession.shared.upload(for: urlRequest, from: payload) guard let httpResponse = (response as? HTTPURLResponse) else { throw Failure() } return "Status: \(httpResponse.statusCode)" }, fetchUserProfile: { let (data, _) = try await URLSession.shared .data(from: URL(string: "https://fakestoreapi.com/users/1")!) let profile = try JSONDecoder().decode(UserProfile.self, from: data) return profile } ) } ================================================ FILE: OnlineStoreTCA/Network/DataLoadingStatus.swift ================================================ // // DataLoadingStatus.swift // OnlineStoreTCA // // Created by Pedro Rojas on 28/08/22. // import Foundation enum DataLoadingStatus { case notStarted case loading case success case error } ================================================ FILE: OnlineStoreTCA/Preview Content/Preview Assets.xcassets/Contents.json ================================================ { "info" : { "author" : "xcode", "version" : 1 } } ================================================ FILE: OnlineStoreTCA/Products/AddToCart/AddToCartButton.swift ================================================ // // AddToCartButton.swift // OnlineStoreTCA // // Created by Pedro Rojas on 20/08/22. // import SwiftUI import ComposableArchitecture struct AddToCartButton: View { let store: StoreOf var body: some View { WithPerceptionTracking { if store.count > 0 { PlusMinusButton(store: self.store) } else { Button { store.send(.didTapPlusButton) } label: { Text("Add to Cart") .padding(10) .background(.blue) .foregroundColor(.white) .cornerRadius(10) } .buttonStyle(.plain) } } } } struct AddToCartButton_Previews: PreviewProvider { static var previews: some View { AddToCartButton( store: Store( initialState: AddToCartDomain.State(), reducer: { AddToCartDomain() } ) ) } } ================================================ FILE: OnlineStoreTCA/Products/AddToCart/AddToCartDomain.swift ================================================ // // PlusMinusDomain.swift // OnlineStoreTCA // // Created by Pedro Rojas on 20/08/22. // import Foundation import ComposableArchitecture @Reducer struct AddToCartDomain { @ObservableState struct State: Equatable { var count = 0 } enum Action: Equatable { case didTapPlusButton case didTapMinusButton } func reduce(into state: inout State, action: Action) -> Effect { switch action { case .didTapPlusButton: state.count += 1 return .none case .didTapMinusButton: state.count -= 1 return .none } } } ================================================ FILE: OnlineStoreTCA/Products/AddToCart/PlusMinusButton.swift ================================================ // // PlusMinusButton.swift // OnlineStoreTCA // // Created by Pedro Rojas on 20/08/22. // import SwiftUI import ComposableArchitecture struct PlusMinusButton: View { let store: StoreOf var body: some View { WithPerceptionTracking { HStack { Button { store.send(.didTapMinusButton) } label: { Text("-") .padding(10) .background(.blue) .foregroundColor(.white) .cornerRadius(10) } .buttonStyle(.plain) Text(store.count.description) .padding(5) Button { store.send(.didTapPlusButton) } label: { Text("+") .padding(10) .background(.blue) .foregroundColor(.white) .cornerRadius(10) } .buttonStyle(.plain) } } } } struct PlusMinusButton_Previews: PreviewProvider { static var previews: some View { PlusMinusButton( store: Store( initialState: AddToCartDomain.State(), reducer: { AddToCartDomain() } ) ) } } ================================================ FILE: OnlineStoreTCA/Products/AddToCart/Test/AddToCartDomainTest.swift ================================================ // // AddToCartDomainTest.swift // OnlineStoreTCA // // Created by Pedro Rojas on 26/08/22. // import ComposableArchitecture import XCTest @testable import OnlineStoreTCA @MainActor class AddToCartDomainTest: XCTestCase { func testIncreaseCounterTappingPlusButtonOnce() async { let store = TestStore( initialState: AddToCartDomain.State(), reducer: { AddToCartDomain() } ) await store.send(\.didTapPlusButton) { $0.count = 1 } } func testIncreaseCounterTappingPlusButtonThreeTimes() async { let store = TestStore( initialState: AddToCartDomain.State(), reducer: { AddToCartDomain() } ) await store.send(\.didTapPlusButton) { $0.count = 1 } await store.send(\.didTapPlusButton) { $0.count = 2 } await store.send(\.didTapPlusButton) { $0.count = 3 } } func testDecreaseCounterTappingPlusButtonOnce() async { let store = TestStore( initialState: AddToCartDomain.State(), reducer: { AddToCartDomain() } ) await store.send(\.didTapMinusButton) { $0.count = -1 } } func testDecreaseCounterTappingPlusButtonThreeTimes() async { let store = TestStore( initialState: AddToCartDomain.State(), reducer: { AddToCartDomain() } ) await store.send(\.didTapMinusButton) { $0.count = -1 } await store.send(\.didTapMinusButton) { $0.count = -2 } await store.send(\.didTapMinusButton) { $0.count = -3 } } func testUpdatingCounterTappingPlusAndMinusButtons() async { let store = TestStore( initialState: AddToCartDomain.State(), reducer: { AddToCartDomain() } ) await store.send(\.didTapMinusButton) { $0.count = -1 } await store.send(\.didTapPlusButton) { $0.count = 0 } await store.send(\.didTapMinusButton) { $0.count = -1 } } } ================================================ FILE: OnlineStoreTCA/Products/Product/Product.swift ================================================ // // Product.swift // OnlineStoreTCA // // Created by Pedro Rojas on 17/08/22. // import Foundation struct Product: Equatable, Identifiable { let id: Int let title: String let price: Double // Update to Currency let description: String let category: String // Update to enum let imageString: String // Add rating later... } extension Product: Decodable { enum ProductKeys: String, CodingKey { case id case title case price case description case category case image } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: ProductKeys.self) self.id = try container.decode(Int.self, forKey: .id) self.title = try container.decode(String.self, forKey: .title) self.price = try container.decode(Double.self, forKey: .price) self.description = try container.decode(String.self, forKey: .description) self.category = try container.decode(String.self, forKey: .category) self.imageString = try container.decode(String.self, forKey: .image) } } extension Product { static var sample: [Product] { [ .init( id: 1, title: "Mens Casual Premium Slim Fit T-Shirts", price: 22.3, description: "Slim-fitting style, contrast raglan long sleeve, three-button henley placket, light weight & soft fabric for breathable and comfortable wearing. And Solid stitched shirts with round neck made for durability and a great fit for casual fashion wear and diehard baseball fans. The Henley style round neckline includes a three-button placket.", category: "men's clothing", imageString: "https://fakestoreapi.com/img/71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg" ), .init( id: 2, title: "Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops", price: 109.95, description: "Your perfect pack for everyday use and walks in the forest. Stash your laptop (up to 15 inches) in the padded sleeve, your everyday", category: "men's clothing", imageString: "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg" ), .init( id: 3, title: "Mens Cotton Jacket", price: 55.99, description: "Great outerwear jackets for Spring/Autumn/Winter, suitable for many occasions, such as working, hiking, camping, mountain/rock climbing, cycling, traveling or other outdoors. Good gift choice for you or your family member. A warm hearted love to Father, husband or son in this thanksgiving or Christmas Day.", category: "men's clothing", imageString: "https://fakestoreapi.com/img/71li-ujtlUL._AC_UX679_.jpg" ) ] } } ================================================ FILE: OnlineStoreTCA/Products/Product/ProductCell.swift ================================================ // // ProductCell.swift // OnlineStoreTCA // // Created by Pedro Rojas on 20/08/22. // import SwiftUI import ComposableArchitecture struct ProductCell: View { let store: StoreOf var body: some View { WithPerceptionTracking { VStack { AsyncImage( url: URL(string: store.product.imageString) ) { image in image .resizable() .aspectRatio(contentMode: .fit) .frame(height: 300) } placeholder: { ProgressView() .frame(height: 300) } VStack(alignment: .leading) { Text(store.product.title) HStack { Text("$\(store.product.price.description)") .font(.custom("AmericanTypewriter", size: 25)) .fontWeight(.bold) Spacer() AddToCartButton( store: self.store.scope( state: \.addToCartState, action: \.addToCart ) ) } } .font(.custom("AmericanTypewriter", size: 20)) } .padding(20) } } } struct ProductCell_Previews: PreviewProvider { static var previews: some View { ProductCell( store: Store( initialState: ProductDomain.State( id: UUID(), product: Product.sample[0] ), reducer: { ProductDomain() } ) ) .previewLayout(.fixed(width: 300, height: 300)) } } ================================================ FILE: OnlineStoreTCA/Products/Product/ProductDomain.swift ================================================ // // ProductDomain.swift // OnlineStoreTCA // // Created by Pedro Rojas on 21/08/22. // import Foundation import ComposableArchitecture @Reducer struct ProductDomain { @ObservableState struct State: Equatable, Identifiable { let id: UUID let product: Product var addToCartState = AddToCartDomain.State() var count: Int { get { addToCartState.count } set { addToCartState.count = newValue } } } enum Action: Equatable { case addToCart(AddToCartDomain.Action) } var body: some ReducerOf { Scope(state: \.addToCartState, action: \.addToCart) { AddToCartDomain() } Reduce { state, action in switch action { case .addToCart(.didTapPlusButton): return .none case .addToCart(.didTapMinusButton): state.addToCartState.count = max(0, state.addToCartState.count) return .none } } } } ================================================ FILE: OnlineStoreTCA/Products/Product/Test/ProductDomainTest.swift ================================================ // // ProductDomainTest.swift // OnlineStoreTCA // // Created by Pedro Rojas on 27/08/22. // import ComposableArchitecture import XCTest @testable import OnlineStoreTCA @MainActor class ProductDomainTest: XCTestCase { func testIncreaseProductCounterTappingPlusButtonOnce() async { let product = Product( id: 1, title: "ProductDemo", price: 10.5, description: "Hi mom!", category: "Category1", imageString: "image" ) let store = TestStore( initialState: ProductDomain.State( id: UUID(), product: product ), reducer: { ProductDomain() } ) await store.send(\.addToCart.didTapPlusButton) { $0.addToCartState = AddToCartDomain.State(count: 1) } } func testIncreaseProductCounterTappingPlusButtonThreeTimes() async { let product = Product( id: 1, title: "ProductDemo", price: 10.5, description: "Hi mom!", category: "Category1", imageString: "image" ) let store = TestStore( initialState: ProductDomain.State( id: UUID(), product: product ), reducer: { ProductDomain() } ) await store.send(\.addToCart.didTapPlusButton) { $0.addToCartState = AddToCartDomain.State(count: 1) } await store.send(\.addToCart.didTapPlusButton) { $0.addToCartState = AddToCartDomain.State(count: 2) } await store.send(\.addToCart.didTapPlusButton) { $0.addToCartState = AddToCartDomain.State(count: 3) } } func testIncreaseProductCounterTappingMinusButtonOnce() async { let product = Product( id: 1, title: "ProductDemo", price: 10.5, description: "Hi mom!", category: "Category1", imageString: "image" ) let store = TestStore( initialState: ProductDomain.State( id: UUID(), product: product ), reducer: { ProductDomain() } ) await store.send(\.addToCart.didTapMinusButton) } func testIncreaseProductCounterTappingMinusButtonThreeTimes() async { let product = Product( id: 1, title: "ProductDemo", price: 10.5, description: "Hi mom!", category: "Category1", imageString: "image" ) let store = TestStore( initialState: ProductDomain.State( id: UUID(), product: product ), reducer: { ProductDomain() } ) // No changes expected! await store.send(\.addToCart.didTapMinusButton) await store.send(\.addToCart.didTapMinusButton) await store.send(\.addToCart.didTapMinusButton) } func testIncreaseProductCounterTappingMinusTwoTimesAndPlusOnce() async { let product = Product( id: 1, title: "ProductDemo", price: 10.5, description: "Hi mom!", category: "Category1", imageString: "image" ) let store = TestStore( initialState: ProductDomain.State( id: UUID(), product: product ), reducer: { ProductDomain() } ) // No changes expected! await store.send(\.addToCart.didTapMinusButton) await store.send(\.addToCart.didTapMinusButton) // Change expected! await store.send(\.addToCart.didTapPlusButton) { $0.addToCartState = AddToCartDomain.State(count: 1) } } } ================================================ FILE: OnlineStoreTCA/Products/ProductList/ErrorView.swift ================================================ // // ErrorView.swift // OnlineStoreTCA // // Created by Pedro Rojas on 25/08/22. // import SwiftUI struct ErrorView: View { let message: String let retryAction: () -> Void var body: some View { VStack { Text(":(") .font(.custom("AmericanTypewriter", size: 50)) Text("") Text(message) .font(.custom("AmericanTypewriter", size: 25)) Button { retryAction() } label: { Text("Retry") .font(.custom("AmericanTypewriter", size: 25)) .foregroundColor(.white) } .frame(width: 100, height: 60) .background(.blue) .cornerRadius(10) .padding() } } } struct ErrorView_Previews: PreviewProvider { static var previews: some View { ErrorView( message: "Oops, we couldn't fetch product list", retryAction: {} ) } } ================================================ FILE: OnlineStoreTCA/Products/ProductList/ProductListDomain.swift ================================================ // // ProductListDomain.swift // OnlineStoreTCA // // Created by Pedro Rojas on 17/08/22. // import Foundation import ComposableArchitecture @Reducer struct ProductListDomain { @ObservableState struct State: Equatable { var dataLoadingStatus = DataLoadingStatus.notStarted @Presents var cartState: CartListDomain.State? var productList: IdentifiedArrayOf = [] var shouldShowError: Bool { dataLoadingStatus == .error } var isLoading: Bool { dataLoadingStatus == .loading } } enum Action: Equatable { case fetchProducts case fetchProductsResponse(TaskResult<[Product]>) case setCartView(isPresented: Bool) case cart(PresentationAction) case product(IdentifiedActionOf) case resetProduct(product: Product) case closeCart } @Dependency(\.apiClient.fetchProducts) var fetchProducts @Dependency(\.uuid) var uuid var body: some ReducerOf { Reduce { state, action in switch action { case .fetchProducts: if state.dataLoadingStatus == .success || state.dataLoadingStatus == .loading { return .none } state.dataLoadingStatus = .loading return .run { send in await send( .fetchProductsResponse( TaskResult { try await self.fetchProducts() } ) ) } case .fetchProductsResponse(.success(let products)): state.dataLoadingStatus = .success state.productList = IdentifiedArrayOf( uniqueElements: products.map { ProductDomain.State( id: uuid(), product: $0 ) } ) return .none case .fetchProductsResponse(.failure(let error)): state.dataLoadingStatus = .error print(error) print("Error getting products, try again later.") return .none case .cart(.presented(let action)): switch action { case .didPressCloseButton: return closeCart(state: &state) case .alert(.presented(.dismissSuccessAlert)): resetProductsToZero(state: &state) return .run { send in await send(.closeCart) } case .cartItem(.element(id: _, action: let action)): switch action { case .deleteCartItem(let product): return .send(.resetProduct(product: product)) } default: return .none } case .cart(.dismiss): return .none case .closeCart: return closeCart(state: &state) case .resetProduct(let product): guard let index = state.productList.firstIndex( where: { $0.product.id == product.id } ) else { return .none } let productStateId = state.productList[index].id state.productList[id: productStateId]?.addToCartState.count = 0 return .none case .setCartView(let isPresented): state.cartState = isPresented ? CartListDomain.State( cartItems: IdentifiedArrayOf( uniqueElements: state .productList .compactMap { state in state.count > 0 ? CartItemDomain.State( id: uuid(), cartItem: CartItem( product: state.product, quantity: state.count ) ) : nil } ) ) : nil return .none case .product: return .none } } .forEach(\.productList, action: \.product) { ProductDomain() } .ifLet(\.$cartState, action: \.cart) { CartListDomain() } } private func closeCart( state: inout State ) -> Effect { state.cartState = nil return .none } private func resetProductsToZero( state: inout State ) { for id in state.productList.map(\.id) { state.productList[id: id]?.count = 0 } } } ================================================ FILE: OnlineStoreTCA/Products/ProductList/ProductListView.swift ================================================ // // ProductListView.swift // OnlineStoreTCA // // Created by Pedro Rojas on 17/08/22. // import SwiftUI import ComposableArchitecture struct ProductListView: View { @Perception.Bindable var store: StoreOf var body: some View { WithPerceptionTracking { NavigationView { Group { if store.isLoading { ProgressView() .frame(width: 100, height: 100) } else if store.shouldShowError { ErrorView( message: "Oops, we couldn't fetch product list", retryAction: { store.send(.fetchProducts) } ) } else { List { ForEach( store.scope( state: \.productList, action: \.product ), id: \.id ) { store in WithPerceptionTracking { ProductCell(store: store) } } } } } .task { store.send(.fetchProducts) } .navigationTitle("Products") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button { store.send(.setCartView(isPresented: true)) } label: { Text("Go to Cart") } } } .sheet( item: $store.scope( state: \.cartState, action: \.cart ) ) { store in CartListView(store: store) } } } } } struct ProductListView_Previews: PreviewProvider { static var previews: some View { ProductListView( store: Store( initialState: ProductListDomain.State() ) { ProductListDomain() } withDependencies: { $0.apiClient.fetchProducts = { Product.sample } $0.apiClient.sendOrder = { _ in "OK" } } ) } } ================================================ FILE: OnlineStoreTCA/Products/ProductList/Test/ProductListDomainTest.swift ================================================ // // ProductListDomainTest.swift // OnlineStoreTCA // // Created by Pedro Rojas on 27/08/22. // import ComposableArchitecture import XCTest @testable import OnlineStoreTCA @MainActor class ProductListDomainTest: XCTestCase { override func setUp() async throws { UUID.uuIdTestCounter = 0 } func testFetchProductsSuccess() async { let products: [Product] = [ .init( id: 1, title: "ProductDemo", price: 10.5, description: "Hi mom!", category: "Category1", imageString: "image" ), .init( id: 2, title: "AnotherProduct", price: 99.99, description: "Hi Dad!", category: "Category2", imageString: "image2" ), ] let store = TestStore( initialState: ProductListDomain.State(), reducer: { ProductListDomain() } ) { $0.apiClient.fetchProducts = { products } $0.uuid = .incrementing } let productId1 = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! let productId2 = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! let identifiedArray = IdentifiedArrayOf( uniqueElements: [ ProductDomain.State( id: productId1, product: products[0] ), ProductDomain.State( id: productId2, product: products[1] ), ] ) await store.send(\.fetchProducts) { $0.dataLoadingStatus = .loading } await store.receive(\.fetchProductsResponse, .success(products)) { $0.productList = identifiedArray $0.dataLoadingStatus = .success } } func testFetchProductsFailure() async { let error = APIClient.Failure() let store = TestStore( initialState: ProductListDomain.State(), reducer: { ProductListDomain() } ) { $0.apiClient.fetchProducts = { throw error } $0.uuid = .incrementing } await store.send(\.fetchProducts) { $0.dataLoadingStatus = .loading } await store.receive(\.fetchProductsResponse, .failure(error)) { $0.productList = [] $0.dataLoadingStatus = .error } } func testResetProductsToZeroAfterPayingOrder() async { let products: [Product] = [ .init( id: 1, title: "ProductDemo", price: 10.5, description: "Hi mom!", category: "Category1", imageString: "image" ), .init( id: 2, title: "AnotherProduct", price: 99.99, description: "Hi Dad!", category: "Category2", imageString: "image2" ), ] let id1 = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! let id2 = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! let identifiedProducts = IdentifiedArrayOf( uniqueElements: [ ProductDomain.State( id: id1, product: products[0] ), ProductDomain.State( id: id2, product: products[1] ), ] ) let store = TestStore( initialState: ProductListDomain.State( productList: identifiedProducts ), reducer: { ProductListDomain() } ) { $0.uuid = .incrementing } await store.send(\.product[id: id1].addToCart.didTapPlusButton) { $0.productList[id: id1]?.addToCartState.count = 1 } await store.send(\.product[id: id1].addToCart.didTapPlusButton) { $0.productList[id: id1]?.addToCartState.count = 2 } let expectedCartState = CartListDomain.State( cartItems: IdentifiedArrayOf( uniqueElements: [ CartItemDomain.State( id: id1, cartItem: CartItem( product: products.first!, quantity: 2 ) ) ] ) ) await store.send(\.setCartView, true) { $0.cartState = expectedCartState } await store.send(\.cart.didPressPayButton) { $0.cartState?.alert = .confirmationAlert(totalPriceString: "$0.0") } await store.send(\.cart.alert.dismissSuccessAlert) { $0.productList[id: id1]?.addToCartState.count = 0 $0.cartState?.alert = nil } await store.receive(\.closeCart) { $0.cartState = nil } } func testItemRemovedFromCart() async { let products: [Product] = [ .init( id: 1, title: "ProductDemo", price: 10.5, description: "Hi mom!", category: "Category1", imageString: "image" ), .init( id: 2, title: "AnotherProduct", price: 99.99, description: "Hi Dad!", category: "Category2", imageString: "image2" ), ] let id1 = UUID(uuidString: "00000000-0000-0000-0000-000000000000")! let id2 = UUID(uuidString: "00000000-0000-0000-0000-000000000001")! let numberOfItems = 2 let identifiedProducts = IdentifiedArrayOf( uniqueElements: [ ProductDomain.State( id: id1, product: products[0], addToCartState: AddToCartDomain.State(count: numberOfItems) ), // This item should not be added: ProductDomain.State( id: id2, product: products[1], addToCartState: AddToCartDomain.State(count: 0) ), ] ) let store = TestStore( initialState: ProductListDomain.State( productList: identifiedProducts ), reducer: { ProductListDomain() } ) { $0.uuid = .incrementing } let expectedCartState = CartListDomain.State( cartItems: IdentifiedArrayOf( uniqueElements: [ CartItemDomain.State( id: id1, cartItem: CartItem( product: products.first!, quantity: numberOfItems ) ) ] ) ) await store.send(\.setCartView, true) { $0.cartState = expectedCartState } await store.send( .cart( .presented( .cartItem( .element(id: id1, action: .deleteCartItem(product: products[0])) ) ) ) ) { $0.cartState?.cartItems = [] } await store.receive(\.cart.getTotalPrice) { $0.cartState?.totalPrice = 0 $0.cartState?.isPayButtonDisable = true } await store.receive(\.resetProduct, products[0]) { $0.productList = identifiedProducts $0.productList[id: id1]?.count = 0 } } } extension UUID { // uuIdTestCounter needs to be set to 0 on setUp() method static var uuIdTestCounter: UInt = 0 static var newUUIDForTest: UUID { defer { uuIdTestCounter += 1 } return UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012x", uuIdTestCounter))")! } } ================================================ FILE: OnlineStoreTCA/Profile/ProfileDomain.swift ================================================ // // ProfileDomain.swift // OnlineStoreTCA // // Created by Pedro Rojas on 25/08/22. // import Foundation import ComposableArchitecture @Reducer struct ProfileDomain { @ObservableState struct State: Equatable { var profile: UserProfile = .default fileprivate var dataState = DataState.notStarted var isLoading: Bool { dataState == .loading } } fileprivate enum DataState { case notStarted case loading case complete } enum Action: Equatable { case fetchUserProfile case fetchUserProfileResponse(TaskResult) } @Dependency(\.apiClient.fetchUserProfile) var fetchUserProfile func reduce(into state: inout State, action: Action) -> Effect { switch action { case .fetchUserProfile: if state.dataState == .complete || state.dataState == .loading { return .none } state.dataState = .loading return .run { send in await send( .fetchUserProfileResponse( TaskResult { try await self.fetchUserProfile() } ) ) } case .fetchUserProfileResponse(.success(let profile)): state.dataState = .complete state.profile = profile return .none case .fetchUserProfileResponse(.failure(let error)): state.dataState = .complete print("Error: \(error)") return .none } } } ================================================ FILE: OnlineStoreTCA/Profile/ProfileView.swift ================================================ // // ProfileView.swift // OnlineStoreTCA // // Created by Pedro Rojas on 25/08/22. // import SwiftUI import ComposableArchitecture struct ProfileView: View { let store: StoreOf var body: some View { WithPerceptionTracking { NavigationView { ZStack { Form { Section { Text(store.profile.firstName.capitalized) + Text(" \(store.profile.lastName.capitalized)") } header: { Text("Full name") } Section { Text(store.profile.email) } header: { Text("Email") } } if store.isLoading { ProgressView() } } .task { store.send(.fetchUserProfile) } .navigationTitle("Profile") } } } } struct ProfileView_Previews: PreviewProvider { static var previews: some View { ProfileView( store: Store(initialState: ProfileDomain.State()) { ProfileDomain() } withDependencies: { $0.apiClient.fetchUserProfile = { .sample } } ) } } ================================================ FILE: OnlineStoreTCA/Profile/UserProfile.swift ================================================ // // UserProfile.swift // OnlineStoreTCA // // Created by Pedro Rojas on 25/08/22. // import Foundation struct UserProfile: Equatable { let id: Int let email: String let firstName: String let lastName: String } extension UserProfile: Decodable { private enum ProfileKeys: String, CodingKey { case id case email case name case firstname case lastname } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: ProfileKeys.self) self.id = try container.decode(Int.self, forKey: .id) self.email = try container.decode(String.self, forKey: .email) let nameContainer = try container.nestedContainer(keyedBy: ProfileKeys.self, forKey: .name) self.firstName = try nameContainer.decode(String.self, forKey: .firstname) self.lastName = try nameContainer.decode(String.self, forKey: .lastname) } } extension UserProfile { static var sample: UserProfile { .init( id: 1, email: "hello@demo.com", firstName: "Pedro", lastName: "Rojas" ) } static var `default`: UserProfile { .init( id: 0, email: "", firstName: "", lastName: "" ) } } ================================================ FILE: OnlineStoreTCA/Root/OnlineStoreTCAApp.swift ================================================ // // OnlineStoreTCAApp.swift // OnlineStoreTCA // // Created by Pedro Rojas on 04/08/22. // import SwiftUI import ComposableArchitecture @main struct OnlineStoreTCAApp: App { var body: some Scene { WindowGroup { RootView( store: Store( initialState: RootDomain.State(), reducer: { RootDomain() } ) ) } } } ================================================ FILE: OnlineStoreTCA/Root/RootDomain.swift ================================================ // // RootDomain.swift // OnlineStoreTCA // // Created by Pedro Rojas on 24/08/22. // import Foundation import ComposableArchitecture @Reducer struct RootDomain { @ObservableState struct State: Equatable { var selectedTab = Tab.products var productListState = ProductListDomain.State() var profileState = ProfileDomain.State() } enum Tab { case products case profile } enum Action: Equatable { case tabSelected(Tab) case productList(ProductListDomain.Action) case profile(ProfileDomain.Action) } var body: some ReducerOf { Reduce { state, action in switch action { case .productList: return .none case .tabSelected(let tab): state.selectedTab = tab return .none case .profile: return .none } } Scope(state: \.productListState, action: \.productList) { ProductListDomain() } Scope(state: \.profileState, action: \.profile) { ProfileDomain() } } } ================================================ FILE: OnlineStoreTCA/Root/RootView.swift ================================================ // // RootView.swift // OnlineStoreTCA // // Created by Pedro Rojas on 24/08/22. // import SwiftUI import ComposableArchitecture struct RootView: View { @Perception.Bindable var store: StoreOf var body: some View { WithPerceptionTracking { TabView( selection: $store.selectedTab.sending(\.tabSelected) ) { ProductListView( store: self.store.scope( state: \.productListState, action: \.productList ) ) .tabItem { Image(systemName: "list.bullet") Text("Products") } .tag(RootDomain.Tab.products) ProfileView( store: self.store.scope( state: \.profileState, action: \.profile ) ) .tabItem { Image(systemName: "person.fill") Text("Profile") } .tag(RootDomain.Tab.profile) } } } } struct RootView_Previews: PreviewProvider { static var previews: some View { RootView( store: Store( initialState: RootDomain.State() ) { RootDomain() } withDependencies: { $0.apiClient.fetchProducts = { Product.sample } $0.apiClient.sendOrder = { _ in "OK" } $0.apiClient.fetchUserProfile = { UserProfile.sample } $0.uuid = .incrementing } ) } } ================================================ FILE: OnlineStoreTCA.xcodeproj/project.pbxproj ================================================ // !$*UTF8*$! { archiveVersion = 1; classes = { }; objectVersion = 55; objects = { /* Begin PBXBuildFile section */ B50F8EFF28B18F020034B039 /* ProductCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50F8EFE28B18F020034B039 /* ProductCell.swift */; }; B50F8F0128B19B880034B039 /* AddToCartButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50F8F0028B19B880034B039 /* AddToCartButton.swift */; }; B50F8F0428B19CD30034B039 /* PlusMinusButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50F8F0328B19CD30034B039 /* PlusMinusButton.swift */; }; B50F8F0628B19E660034B039 /* AddToCartDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50F8F0528B19E660034B039 /* AddToCartDomain.swift */; }; B50F8F0C28B2C1A70034B039 /* ProductDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50F8F0B28B2C1A70034B039 /* ProductDomain.swift */; }; B50F8F1028B3D7FB0034B039 /* CartCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50F8F0F28B3D7FB0034B039 /* CartCell.swift */; }; B50F8F1328B482E80034B039 /* CartItemDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50F8F1228B482E80034B039 /* CartItemDomain.swift */; }; B51F8DF728BF2FF20047E7D9 /* CartListDomainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51F8DF628BF2FF20047E7D9 /* CartListDomainTest.swift */; }; B525334728AD3F6000B29A17 /* ProductListDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B525334628AD3F6000B29A17 /* ProductListDomain.swift */; }; B525334C28ADCBCE00B29A17 /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = B525334B28ADCBCE00B29A17 /* Product.swift */; }; B525334E28ADD6DE00B29A17 /* ProductListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B525334D28ADD6DE00B29A17 /* ProductListView.swift */; }; B525335128AE87C700B29A17 /* CartListDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B525335028AE87C700B29A17 /* CartListDomain.swift */; }; B525335328AE883B00B29A17 /* CartItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B525335228AE883B00B29A17 /* CartItem.swift */; }; B525335528AE8C5600B29A17 /* CartListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B525335428AE8C5600B29A17 /* CartListView.swift */; }; B5389D1528B9AAA4002BF611 /* OnlineStoreTCATests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5389D1428B9AAA4002BF611 /* OnlineStoreTCATests.swift */; }; B5389D1B28B9AAC6002BF611 /* AddToCartDomainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B556E18428B919FB00CB51F4 /* AddToCartDomainTest.swift */; }; B556E16428B5C44000CB51F4 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B556E16328B5C44000CB51F4 /* APIClient.swift */; }; B556E16B28B5CEA900CB51F4 /* ComposableArchitecture in Frameworks */ = {isa = PBXBuildFile; productRef = B556E16A28B5CEA900CB51F4 /* ComposableArchitecture */; }; B556E17928B671AC00CB51F4 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B556E17828B671AC00CB51F4 /* RootView.swift */; }; B556E17B28B6740A00CB51F4 /* RootDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B556E17A28B6740A00CB51F4 /* RootDomain.swift */; }; B556E17D28B7BE1600CB51F4 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B556E17C28B7BE1600CB51F4 /* ProfileView.swift */; }; B556E17F28B7BE2D00CB51F4 /* ProfileDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = B556E17E28B7BE2D00CB51F4 /* ProfileDomain.swift */; }; B556E18128B7BEC200CB51F4 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B556E18028B7BEC200CB51F4 /* UserProfile.swift */; }; B556E18328B84F1500CB51F4 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B556E18228B84F1500CB51F4 /* ErrorView.swift */; }; B5A5CFB5289C257900AC3E5E /* OnlineStoreTCAApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5A5CFB4289C257900AC3E5E /* OnlineStoreTCAApp.swift */; }; B5A5CFB9289C257B00AC3E5E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5A5CFB8289C257B00AC3E5E /* Assets.xcassets */; }; B5A5CFBC289C257B00AC3E5E /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5A5CFBB289C257B00AC3E5E /* Preview Assets.xcassets */; }; B5BE68DB28BA6EA8008FC353 /* ProductDomainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BE68D928BA6EA4008FC353 /* ProductDomainTest.swift */; }; B5BE68DF28BB23B1008FC353 /* ProductListDomainTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BE68DD28BB23AC008FC353 /* ProductListDomainTest.swift */; }; B5BE68E128BBAE93008FC353 /* DataLoadingStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5BE68E028BBAE93008FC353 /* DataLoadingStatus.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ B5389D1628B9AAA4002BF611 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = B5A5CFA9289C257900AC3E5E /* Project object */; proxyType = 1; remoteGlobalIDString = B5A5CFB0289C257900AC3E5E; remoteInfo = OnlineStoreTCA; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ B50F8EFE28B18F020034B039 /* ProductCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCell.swift; sourceTree = ""; }; B50F8F0028B19B880034B039 /* AddToCartButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToCartButton.swift; sourceTree = ""; }; B50F8F0328B19CD30034B039 /* PlusMinusButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlusMinusButton.swift; sourceTree = ""; }; B50F8F0528B19E660034B039 /* AddToCartDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToCartDomain.swift; sourceTree = ""; }; B50F8F0B28B2C1A70034B039 /* ProductDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDomain.swift; sourceTree = ""; }; B50F8F0F28B3D7FB0034B039 /* CartCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartCell.swift; sourceTree = ""; }; B50F8F1228B482E80034B039 /* CartItemDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartItemDomain.swift; sourceTree = ""; }; B51F8DF628BF2FF20047E7D9 /* CartListDomainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartListDomainTest.swift; sourceTree = ""; }; B525334628AD3F6000B29A17 /* ProductListDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListDomain.swift; sourceTree = ""; }; B525334B28ADCBCE00B29A17 /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = ""; }; B525334D28ADD6DE00B29A17 /* ProductListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListView.swift; sourceTree = ""; }; B525335028AE87C700B29A17 /* CartListDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartListDomain.swift; sourceTree = ""; }; B525335228AE883B00B29A17 /* CartItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartItem.swift; sourceTree = ""; }; B525335428AE8C5600B29A17 /* CartListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartListView.swift; sourceTree = ""; }; B5389D1228B9AAA3002BF611 /* OnlineStoreTCATests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OnlineStoreTCATests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B5389D1428B9AAA4002BF611 /* OnlineStoreTCATests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineStoreTCATests.swift; sourceTree = ""; }; B556E16328B5C44000CB51F4 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; B556E17828B671AC00CB51F4 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; B556E17A28B6740A00CB51F4 /* RootDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootDomain.swift; sourceTree = ""; }; B556E17C28B7BE1600CB51F4 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; B556E17E28B7BE2D00CB51F4 /* ProfileDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileDomain.swift; sourceTree = ""; }; B556E18028B7BEC200CB51F4 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; B556E18228B84F1500CB51F4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; B556E18428B919FB00CB51F4 /* AddToCartDomainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToCartDomainTest.swift; sourceTree = ""; }; B5A5CFB1289C257900AC3E5E /* OnlineStoreTCA.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OnlineStoreTCA.app; sourceTree = BUILT_PRODUCTS_DIR; }; B5A5CFB4289C257900AC3E5E /* OnlineStoreTCAApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineStoreTCAApp.swift; sourceTree = ""; }; B5A5CFB8289C257B00AC3E5E /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; B5A5CFBB289C257B00AC3E5E /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; B5BE68D928BA6EA4008FC353 /* ProductDomainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDomainTest.swift; sourceTree = ""; }; B5BE68DD28BB23AC008FC353 /* ProductListDomainTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductListDomainTest.swift; sourceTree = ""; }; B5BE68E028BBAE93008FC353 /* DataLoadingStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLoadingStatus.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ B5389D0F28B9AAA3002BF611 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; B5A5CFAE289C257900AC3E5E /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( B556E16B28B5CEA900CB51F4 /* ComposableArchitecture in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ B50F8F0228B19CBD0034B039 /* AddToCart */ = { isa = PBXGroup; children = ( B5BE68D728BA6E63008FC353 /* Test */, B50F8F0028B19B880034B039 /* AddToCartButton.swift */, B50F8F0328B19CD30034B039 /* PlusMinusButton.swift */, B50F8F0528B19E660034B039 /* AddToCartDomain.swift */, ); path = AddToCart; sourceTree = ""; }; B50F8F0D28B2C1C90034B039 /* ProductList */ = { isa = PBXGroup; children = ( B5BE68DC28BB2399008FC353 /* Test */, B525334628AD3F6000B29A17 /* ProductListDomain.swift */, B525334D28ADD6DE00B29A17 /* ProductListView.swift */, B556E18228B84F1500CB51F4 /* ErrorView.swift */, ); path = ProductList; sourceTree = ""; }; B50F8F0E28B2C1D40034B039 /* Product */ = { isa = PBXGroup; children = ( B5BE68D828BA6E94008FC353 /* Test */, B525334B28ADCBCE00B29A17 /* Product.swift */, B50F8EFE28B18F020034B039 /* ProductCell.swift */, B50F8F0B28B2C1A70034B039 /* ProductDomain.swift */, ); path = Product; sourceTree = ""; }; B50F8F1128B4814E0034B039 /* Cart */ = { isa = PBXGroup; children = ( B525335228AE883B00B29A17 /* CartItem.swift */, B50F8F0F28B3D7FB0034B039 /* CartCell.swift */, B50F8F1228B482E80034B039 /* CartItemDomain.swift */, ); path = Cart; sourceTree = ""; }; B51F8DF528BF2F7C0047E7D9 /* Test */ = { isa = PBXGroup; children = ( B51F8DF628BF2FF20047E7D9 /* CartListDomainTest.swift */, ); path = Test; sourceTree = ""; }; B525334328AD3DCE00B29A17 /* Products */ = { isa = PBXGroup; children = ( B50F8F0E28B2C1D40034B039 /* Product */, B50F8F0D28B2C1C90034B039 /* ProductList */, B50F8F0228B19CBD0034B039 /* AddToCart */, ); path = Products; sourceTree = ""; }; B525334428AD3DD500B29A17 /* Profile */ = { isa = PBXGroup; children = ( B556E17C28B7BE1600CB51F4 /* ProfileView.swift */, B556E17E28B7BE2D00CB51F4 /* ProfileDomain.swift */, B556E18028B7BEC200CB51F4 /* UserProfile.swift */, ); path = Profile; sourceTree = ""; }; B525334528AD3DDF00B29A17 /* Root */ = { isa = PBXGroup; children = ( B5A5CFB4289C257900AC3E5E /* OnlineStoreTCAApp.swift */, B556E17828B671AC00CB51F4 /* RootView.swift */, B556E17A28B6740A00CB51F4 /* RootDomain.swift */, ); path = Root; sourceTree = ""; }; B525334F28AE87B500B29A17 /* CartList */ = { isa = PBXGroup; children = ( B51F8DF528BF2F7C0047E7D9 /* Test */, B50F8F1128B4814E0034B039 /* Cart */, B525335028AE87C700B29A17 /* CartListDomain.swift */, B525335428AE8C5600B29A17 /* CartListView.swift */, ); path = CartList; sourceTree = ""; }; B5389D1328B9AAA4002BF611 /* OnlineStoreTCATests */ = { isa = PBXGroup; children = ( B5389D1428B9AAA4002BF611 /* OnlineStoreTCATests.swift */, ); path = OnlineStoreTCATests; sourceTree = ""; }; B556E16228B5BD6000CB51F4 /* Network */ = { isa = PBXGroup; children = ( B556E16328B5C44000CB51F4 /* APIClient.swift */, B5BE68E028BBAE93008FC353 /* DataLoadingStatus.swift */, ); path = Network; sourceTree = ""; }; B5A5CFA8289C257900AC3E5E = { isa = PBXGroup; children = ( B5A5CFB3289C257900AC3E5E /* OnlineStoreTCA */, B5389D1328B9AAA4002BF611 /* OnlineStoreTCATests */, B5A5CFB2289C257900AC3E5E /* Products */, ); sourceTree = ""; }; B5A5CFB2289C257900AC3E5E /* Products */ = { isa = PBXGroup; children = ( B5A5CFB1289C257900AC3E5E /* OnlineStoreTCA.app */, B5389D1228B9AAA3002BF611 /* OnlineStoreTCATests.xctest */, ); name = Products; sourceTree = ""; }; B5A5CFB3289C257900AC3E5E /* OnlineStoreTCA */ = { isa = PBXGroup; children = ( B556E16228B5BD6000CB51F4 /* Network */, B525334F28AE87B500B29A17 /* CartList */, B525334528AD3DDF00B29A17 /* Root */, B525334428AD3DD500B29A17 /* Profile */, B525334328AD3DCE00B29A17 /* Products */, B5A5CFB8289C257B00AC3E5E /* Assets.xcassets */, B5A5CFBA289C257B00AC3E5E /* Preview Content */, ); path = OnlineStoreTCA; sourceTree = ""; }; B5A5CFBA289C257B00AC3E5E /* Preview Content */ = { isa = PBXGroup; children = ( B5A5CFBB289C257B00AC3E5E /* Preview Assets.xcassets */, ); path = "Preview Content"; sourceTree = ""; }; B5BE68D728BA6E63008FC353 /* Test */ = { isa = PBXGroup; children = ( B556E18428B919FB00CB51F4 /* AddToCartDomainTest.swift */, ); path = Test; sourceTree = ""; }; B5BE68D828BA6E94008FC353 /* Test */ = { isa = PBXGroup; children = ( B5BE68D928BA6EA4008FC353 /* ProductDomainTest.swift */, ); path = Test; sourceTree = ""; }; B5BE68DC28BB2399008FC353 /* Test */ = { isa = PBXGroup; children = ( B5BE68DD28BB23AC008FC353 /* ProductListDomainTest.swift */, ); path = Test; sourceTree = ""; }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ B5389D1128B9AAA3002BF611 /* OnlineStoreTCATests */ = { isa = PBXNativeTarget; buildConfigurationList = B5389D1A28B9AAA4002BF611 /* Build configuration list for PBXNativeTarget "OnlineStoreTCATests" */; buildPhases = ( B5389D0E28B9AAA3002BF611 /* Sources */, B5389D0F28B9AAA3002BF611 /* Frameworks */, B5389D1028B9AAA3002BF611 /* Resources */, ); buildRules = ( ); dependencies = ( B5389D1728B9AAA4002BF611 /* PBXTargetDependency */, ); name = OnlineStoreTCATests; productName = OnlineStoreTCATests; productReference = B5389D1228B9AAA3002BF611 /* OnlineStoreTCATests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; B5A5CFB0289C257900AC3E5E /* OnlineStoreTCA */ = { isa = PBXNativeTarget; buildConfigurationList = B5A5CFBF289C257B00AC3E5E /* Build configuration list for PBXNativeTarget "OnlineStoreTCA" */; buildPhases = ( B5A5CFAD289C257900AC3E5E /* Sources */, B5A5CFAE289C257900AC3E5E /* Frameworks */, B5A5CFAF289C257900AC3E5E /* Resources */, ); buildRules = ( ); dependencies = ( ); name = OnlineStoreTCA; packageProductDependencies = ( B556E16A28B5CEA900CB51F4 /* ComposableArchitecture */, ); productName = OnlineStoreTCA; productReference = B5A5CFB1289C257900AC3E5E /* OnlineStoreTCA.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ B5A5CFA9289C257900AC3E5E /* Project object */ = { isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1400; LastUpgradeCheck = 1330; TargetAttributes = { B5389D1128B9AAA3002BF611 = { CreatedOnToolsVersion = 14.0; TestTargetID = B5A5CFB0289C257900AC3E5E; }; B5A5CFB0289C257900AC3E5E = { CreatedOnToolsVersion = 13.3; }; }; }; buildConfigurationList = B5A5CFAC289C257900AC3E5E /* Build configuration list for PBXProject "OnlineStoreTCA" */; compatibilityVersion = "Xcode 13.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); mainGroup = B5A5CFA8289C257900AC3E5E; packageReferences = ( B556E16928B5CEA900CB51F4 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */, ); productRefGroup = B5A5CFB2289C257900AC3E5E /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( B5A5CFB0289C257900AC3E5E /* OnlineStoreTCA */, B5389D1128B9AAA3002BF611 /* OnlineStoreTCATests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ B5389D1028B9AAA3002BF611 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; B5A5CFAF289C257900AC3E5E /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( B5A5CFBC289C257B00AC3E5E /* Preview Assets.xcassets in Resources */, B5A5CFB9289C257B00AC3E5E /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ B5389D0E28B9AAA3002BF611 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( B5389D1B28B9AAC6002BF611 /* AddToCartDomainTest.swift in Sources */, B5389D1528B9AAA4002BF611 /* OnlineStoreTCATests.swift in Sources */, B5BE68DB28BA6EA8008FC353 /* ProductDomainTest.swift in Sources */, B5BE68DF28BB23B1008FC353 /* ProductListDomainTest.swift in Sources */, B51F8DF728BF2FF20047E7D9 /* CartListDomainTest.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; B5A5CFAD289C257900AC3E5E /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( B525335128AE87C700B29A17 /* CartListDomain.swift in Sources */, B50F8F0128B19B880034B039 /* AddToCartButton.swift in Sources */, B50F8F1328B482E80034B039 /* CartItemDomain.swift in Sources */, B525335528AE8C5600B29A17 /* CartListView.swift in Sources */, B556E18328B84F1500CB51F4 /* ErrorView.swift in Sources */, B50F8F0628B19E660034B039 /* AddToCartDomain.swift in Sources */, B5BE68E128BBAE93008FC353 /* DataLoadingStatus.swift in Sources */, B556E17B28B6740A00CB51F4 /* RootDomain.swift in Sources */, B525334E28ADD6DE00B29A17 /* ProductListView.swift in Sources */, B5A5CFB5289C257900AC3E5E /* OnlineStoreTCAApp.swift in Sources */, B50F8F1028B3D7FB0034B039 /* CartCell.swift in Sources */, B556E17F28B7BE2D00CB51F4 /* ProfileDomain.swift in Sources */, B556E17928B671AC00CB51F4 /* RootView.swift in Sources */, B556E17D28B7BE1600CB51F4 /* ProfileView.swift in Sources */, B556E18128B7BEC200CB51F4 /* UserProfile.swift in Sources */, B556E16428B5C44000CB51F4 /* APIClient.swift in Sources */, B525334728AD3F6000B29A17 /* ProductListDomain.swift in Sources */, B50F8F0C28B2C1A70034B039 /* ProductDomain.swift in Sources */, B50F8EFF28B18F020034B039 /* ProductCell.swift in Sources */, B525335328AE883B00B29A17 /* CartItem.swift in Sources */, B50F8F0428B19CD30034B039 /* PlusMinusButton.swift in Sources */, B525334C28ADCBCE00B29A17 /* Product.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ B5389D1728B9AAA4002BF611 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = B5A5CFB0289C257900AC3E5E /* OnlineStoreTCA */; targetProxy = B5389D1628B9AAA4002BF611 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ B5389D1828B9AAA4002BF611 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = LHJA6J73E9; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.swiftandtips.OnlineStoreTCATests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OnlineStoreTCA.app/OnlineStoreTCA"; }; name = Debug; }; B5389D1928B9AAA4002BF611 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = LHJA6J73E9; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.swiftandtips.OnlineStoreTCATests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OnlineStoreTCA.app/OnlineStoreTCA"; }; name = Release; }; B5A5CFBD289C257B00AC3E5E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; }; B5A5CFBE289C257B00AC3E5E /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; CLANG_WARN_BOOL_CONVERSION = YES; CLANG_WARN_COMMA = YES; CLANG_WARN_CONSTANT_CONVERSION = YES; CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; CLANG_WARN_DOCUMENTATION_COMMENTS = YES; CLANG_WARN_EMPTY_BODY = YES; CLANG_WARN_ENUM_CONVERSION = YES; CLANG_WARN_INFINITE_RECURSION = YES; CLANG_WARN_INT_CONVERSION = YES; CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; CLANG_WARN_STRICT_PROTOTYPES = YES; CLANG_WARN_SUSPICIOUS_MOVE = YES; CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; SDKROOT = iphoneos; SWIFT_COMPILATION_MODE = wholemodule; SWIFT_OPTIMIZATION_LEVEL = "-O"; VALIDATE_PRODUCT = YES; }; name = Release; }; B5A5CFC0289C257B00AC3E5E /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"OnlineStoreTCA/Preview Content\""; DEVELOPMENT_TEAM = LHJA6J73E9; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.swiftandtips.OnlineStoreTCA; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; B5A5CFC1289C257B00AC3E5E /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"OnlineStoreTCA/Preview Content\""; DEVELOPMENT_TEAM = LHJA6J73E9; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.swiftandtips.OnlineStoreTCA; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ B5389D1A28B9AAA4002BF611 /* Build configuration list for PBXNativeTarget "OnlineStoreTCATests" */ = { isa = XCConfigurationList; buildConfigurations = ( B5389D1828B9AAA4002BF611 /* Debug */, B5389D1928B9AAA4002BF611 /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; B5A5CFAC289C257900AC3E5E /* Build configuration list for PBXProject "OnlineStoreTCA" */ = { isa = XCConfigurationList; buildConfigurations = ( B5A5CFBD289C257B00AC3E5E /* Debug */, B5A5CFBE289C257B00AC3E5E /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; B5A5CFBF289C257B00AC3E5E /* Build configuration list for PBXNativeTarget "OnlineStoreTCA" */ = { isa = XCConfigurationList; buildConfigurations = ( B5A5CFC0289C257B00AC3E5E /* Debug */, B5A5CFC1289C257B00AC3E5E /* Release */, ); defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ B556E16928B5CEA900CB51F4 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/pointfreeco/swift-composable-architecture.git"; requirement = { kind = exactVersion; version = 1.14.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ B556E16A28B5CEA900CB51F4 /* ComposableArchitecture */ = { isa = XCSwiftPackageProductDependency; package = B556E16928B5CEA900CB51F4 /* XCRemoteSwiftPackageReference "swift-composable-architecture" */; productName = ComposableArchitecture; }; /* End XCSwiftPackageProductDependency section */ }; rootObject = B5A5CFA9289C257900AC3E5E /* Project object */; } ================================================ FILE: OnlineStoreTCATests/OnlineStoreTCATests.swift ================================================ // // OnlineStoreTCATests.swift // OnlineStoreTCATests // // Created by Pedro Rojas on 26/08/22. // import XCTest final class OnlineStoreTCATests: XCTestCase { override func setUpWithError() throws { // Put setup code here. This method is called before the invocation of each test method in the class. } override func tearDownWithError() throws { // Put teardown code here. This method is called after the invocation of each test method in the class. } func testExample() throws { // This is an example of a functional test case. // Use XCTAssert and related functions to verify your tests produce the correct results. // Any test you write for XCTest can be annotated as throws and async. // Mark your test throws to produce an unexpected failure when your test encounters an uncaught error. // Mark your test async to allow awaiting for asynchronous code to complete. Check the results with assertions afterwards. } func testPerformanceExample() throws { // This is an example of a performance test case. measure { // Put the code you want to measure the time of here. } } } ================================================ FILE: README.md ================================================ # Before starting - This demo was implemented using version [1.15.2](https://pointfreeco.github.io/swift-composable-architecture/1.14.0/documentation/composablearchitecture/) of TCA. - The demo runs on iOS 17.6 and above. - All credits about TCA go to [Brandon Willams](https://twitter.com/mbrandonw), [Stephen Celis](https://twitter.com/stephencelis) and the incredible team at [pointfree.co](https://www.pointfree.co/) ❤️. # Online Store made with Composable Architecture (TCA) The purpose of this demo is to provide an introduction to the main concepts of TCA. If you are new to TCA, I **highly** recommend starting with the README from the [main repository](https://github.com/pointfreeco/swift-composable-architecture) and watching the informative [Tour of TCA](https://www.pointfree.co/collections/composable-architecture/a-tour-of-the-composable-architecture). These resources will provide you with a solid foundation and a comprehensive understanding of the TCA framework. ## Content * [Motivation](#motivation) * [Screenshots of the app](#screenshots) * [The basics](#the-basics) * [Archiecture Diagram](#archiecture-diagram) * [Hello World Example](#hello-world-example) * [Composition](#composition) * [Body to compose multiple Reducers](#body-to-compose-multiple-reducers) * [Single state operators](#single-state-operators) * [store.scope(state:action:)](#storescopestateaction) * [Scope in Reducers](#scope-in-reducers) * [Collection of states](#collection-of-states) * [forEach in Reducer](#foreach-in-reducer) * [Dependencies](#dependencies) * [Side Effects](#side-effects) * [Network Calls](#network-calls) * [Navigation](#navigation) * [Alerts](#alerts) * [Sheets](#sheets) * [Testing](#testing) * [Basics](#testing-basics) * [Side Effects](#testing-side-effects) * [CasePathable](#testing-CasePathable) * [Other Topics](#other-topics) * [Optional States](#optional-states) * [Private Actions](#private-actions) * [Making a Root Domain with Tab View](#making-a-root-domain-with-tab-view) * [Contact](#contact) ## Motivation **TL;DR:** This project aims to build an app using TCA, striking a balance between simplicity and complexity. It focuses on exploring the most important use cases of TCA while providing concise and accessible documentation for new learners. The goal is to create a valuable learning resource that offers practical insights into using TCA effectively. I aimed to showcase the power of the TCA architecture in building robust applications for the Apple ecosystem, including iOS, macOS, and more excitingly, its future expansion beyond the Apple world! 🚀 While there are many articles available that demonstrate simple one-screen applications to introduce TCA's core concepts, I noticed a gap between these basic demos and real-world applications like [isoword](https://github.com/pointfreeco/isowords), which can be complex and challenging to understand certain important use cases (like navigation and how reducers are glued). In this demo, I have implemented a minimal online store that connects to a real network API (https://fakestoreapi.com). It features a product list, the ability to add items to the cart, and the functionality to place orders. While the requests are not processed in real-time (as it uses a fake API), the network status is simulated, allowing you to experience the interaction and mapping of network calls using TCA. While this demo may not be a full-scale real-world application, it includes enough reducers to illustrate how data can be effectively connected and how domains can be isolated to handle specific components within the app (e.g., Tabs -> Product List -> Product Cell -> Add to Cart button). Furthermore, I have created tests to demonstrate one of TCA's key features: ensuring that tests fail if the expected state mutations are not captured accurately. This showcases how TCA promotes testability and helps ensure the correctness of your application. If you're looking to dive into TCA, this demo provides a valuable middle ground between simple examples and complex projects, offering concise documentation and practical insights into working with TCA in a more realistic application setting. Any feedback is welcome! 🙌🏻 ## Screenshots ### Tabs || ### Cart || ## The basics ### Archiecture Diagram ### Hello World Example Consider the following implementation of a simple app using TCA, where you will have two buttons: one to increment a counter displayed on the screen and the other to decrement it. Here's an example of how this app would be coded with TCA: 1. A struct that will represent the domain of the feature. This struct must conform `ReducerProtocol` protocol and providing `State` struct, `Action` enum and `reduce` method. ```swift struct CounterDomain: ReducerProtocol { struct State { // State of the feature } enum Action { // actions that use can do in the app } func reduce(into state: inout State, action: Action) -> EffectTask { // Method that will mutate the state given an action. } } ``` 2. The view that is presented in the screen will display the current state of the app. ```swift struct State: Equatable { var counter = 0 } ``` 3. When the user presses a button (let's say increase button), it will internally send an action to the store. ```swift enum Action: Equatable { case increaseCounter case decreaseCounter } ``` 4. The action will be received by the reducer and proceed to mutate the state. Reducer MUST also return an effect, that represent logic from the "outside world" (network calls, notifications, database, etc). If no effect is needed, just return `EffectTask.none` . ```swift func reduce(into state: inout State, action: Action) -> EffectTask { switch action { case .increaseCounter: state.counter += 1 return .none case .decreaseCounter: state.counter -= 1 return .none } } ``` 5. Once the mutation is done and the reducer returned the effect, the view will render the update in the screen. 6. To observe state changes in TCA, we need an object called `viewStore`, that in this example is wrapped within WithViewStore view. We can send an action from the view to the store using `viewStore.send()` and an `Action` value. ```swift struct ContentView: View { let store: Store var body: some View { WithViewStore(self.store) { viewStore in HStack { Button { viewStore.send(.decreaseCounter) } label: { Text("-") .padding(10) .background(.blue) .foregroundColor(.white) .cornerRadius(10) } .buttonStyle(.plain) Text(viewStore.counter.description) .padding(5) Button { viewStore.send(.increaseCounter) } label: { Text("+") .padding(10) .background(.blue) .foregroundColor(.white) .cornerRadius(10) } .buttonStyle(.plain) } } } } ``` 7. View is initialized by a `Store` object. ```swift ContentView( store: Store( initialState: CounterDomain.State(), reducer: CounterDomain() ) ) ``` If you want to learn more about the basics, check out the following [video](https://youtu.be/SfFDj6qT-xg) > Note: The videos shared here were made using the legacy version of TCA with Environment and without `ReducerProtocol`. If you want to see the legacy version of TCA, check out this [branch](https://github.com/pitt500/OnlineStoreTCA/tree/legacy-tca-with-environment). ## Composition Composition refers to the process of building complex software systems by combining smaller, reusable software components. Take a look to this image: We started with a simple button counter, then we add an extra state to display text, next we put the whole button in a Product cell, and finally, each product cell will be part of a Product list. That is composition! ### Body to compose multiple Reducers In the previous example, we demonstrated the usage of `reduce(into:action:)` to create our reducer function and define how state will be modified for each action. However, it's important to note that this method is suitable only for leaf components, which refer to the smallest components in your application. For larger components, we can leverage the `body` property provided by the `ReducerProtocol`. This property enables you to combine multiple reducers, facilitating the creation of more comprehensive components. By utilizing the `body` property, you can effectively compose and manage the state mutations of these larger components. ```swift var body: some ReducerProtocol { ChildReducer1() Reduce { state, action in switch action { case .increaseCounter: state.counter += 1 return .none case .decreaseCounter: state.counter -= 1 return .none } } ChildReducer2() } ``` The `Reduce` closure will always encapsulate the logic from the parent domain. To understand how to combine additional components, please continue reading below. > Compared to the previous version of TCA without `ReducerProtocol`, the order of child reducers will not affect the result. Parent Reducer (`Reduce`) will be always executed at the end. ### Single state operators For single states (all except collections/lists), TCA provides operators to glue the components and make bigger ones. #### store.scope(state:action:) `store.scope` is an operator used in views to get the child domain's (`AddToCartDomain`) state and action from parent domain (`ProductDomain`) to initialize subviews. For example, the `ProductDomain` below contains two properties as part of its state: `product` and `addToCartState`. ```swift struct ProductDomain: ReducerProtocol { struct State: Equatable, Identifiable { let product: Product var addToCartState = AddToCartDomain.State() } // ... ``` Furthermore, we utilize an action with an associated value that encapsulates all actions from the child domain, providing a comprehensive and cohesive approach. ```swift struct ProductDomain: ReducerProtocol { // State ... enum Action { case addToCart(AddToCartDomain.Action) } // ... ``` Let's consider the scenario where we need to configure the `ProductCell` view below. The `ProductCell` is designed to handle the `ProductDomain`, while we need to provide some information to initialize the `AddToCartButton`. However, the `AddToCartButton` is only aware of its own domain, `AddToCartDomain`, and not the `ProductDomain`. To address this, we can use the `scope` method from `store` to get the child's state and action from parent domain. This enables us to narrow down the scope of the button to focus solely on its own functionality. ```swift struct ProductCell: View { let store: Store var body: some View { WithViewStore(self.store) { viewStore in // More views here ... AddToCartButton( store: self.store.scope( state: \.addToCartState, action: ProductDomain.Action.addToCart ) ) } } ``` By employing this approach, the `AddToCartDomain` will solely possess knowledge of its own state and remain unaware of any product-related information. #### Scope in Reducers `Scope` is utilized within the `body` to seamlessly transform the child reducer (`AddToCart`) into a compatible form that aligns with the parent reducer (`Product`). This allows for smooth integration and interaction between the two. ```swift var body: some ReducerProtocol { Scope(state: \.addToCartState, action: /ProductDomain.Action.addToCart) { AddToCartDomain() } Reduce { state, action in // Parent Reducer logic ... } } ``` This transformation becomes highly valuable when combining multiple reducers to construct a more complex component. > In earlier versions, the `pullback` and `combine` operators were employed to carry out the same operation. You can watch this [video](https://youtu.be/Zf2pFEa3uew). ### Collection of states Are you looking to manage a collection of states? TCA offers excellent support for that as well! In this particular example, instead of using a regular array, TCA requires a list of (`Product`) states, which can be achieved by utilizing `IdentifiedArray`: ```swift struct ProductListDomain: ReducerProtocol { struct State: Equatable { var productList: IdentifiedArrayOf = [] // ... } // ... } ``` #### forEach in Reducer The `forEach` operator functions similarly to the [`Scope`](#scope-in-reducers) operator, with the distinction that it operates on a collection of states. It effectively transforms the child reducers into compatible forms that align with the parent reducer. ```swift struct ProductListDomain: ReducerProtocol { // State and Actions ... var body: some ReducerProtocol { Reduce { state, action in // Parent Reducer... } .forEach( \.productList, action: /ProductListDomain.Action.product(id:action:) ) { ProductDomain() } } } ``` Subsequently, in the user interface, we employ `ForEachStore` and `store.scope` to iterate through all the (`Product`) states and actions. This enables us to send actions to the corresponding cell and modify its state accordingly. ```swift List { ForEachStore( self.store.scope( state: \.productListState, action: ProductListDomain.Action .product(id: action:) ) ) { ProductCell(store: $0) } } ``` > There's a legacy `forEach` operator, If you want to learn more, check out this [video](https://youtu.be/sid-zfggYhQ) ## Dependencies In previous iterations of TCA, `Environment` played a crucial role in consolidating all the dependencies utilized by a domain. With the introduction of the [`ReducerProtocol`](https://www.pointfree.co/blog/posts/81-announcing-the-reducer-protocol), we have eliminated the concept of `Environment`. As a result, dependencies now reside directly within the domain. ```swift struct ProductListDomain: ReducerProtocol { // State ... // Actions... var fetchProducts: () async throws -> [Product] var sendOrder: ([CartItem]) async throws -> String var uuid: () -> UUID // Reducer ... } ``` Nevertheless, we have the option to leverage the [Dependencies Framework](https://github.com/pointfreeco/swift-dependencies) to achieve a more enhanced approach in managing our dependencies: ```swift struct ProductListDomain: ReducerProtocol { // State ... // Actions... @Dependency(\.apiClient.fetchProducts) var fetchProducts @Dependency(\.apiClient.sendOrder) var sendOrder @Dependency(\.uuid) var uuid // Reducer ... } ``` > If you want to learn more about how Environment object works on TCA, take a look to this [video](https://youtu.be/sid-zfggYhQ?list=PLHWvYoDHvsOVo4tklgLW1g7gy4Kmk4kjw&t=103) ## Side Effects A side effect refers to an observable change that arises when executing a function or method. This encompasses actions such as modifying state outside the function, performing I/O operations to a file or making network requests. TCA facilitates the encapsulation of such side effects through the use of `EffectTask` objects. > If you want to learn more about side effects, check out this [video](https://youtu.be/t3HHam3GYkU) ### Network calls Network calls are a fundamental aspect of mobile development, and TCA offers robust tools to handle them efficiently. As network calls are considered external interactions or [side effects](#side-effects), TCA utilizes the `EffectTask` object to encapsulate these calls. Specifically, network calls are encapsulated within the `EffectTask.task` construct, allowing for streamlined management of asynchronous operations within the TCA framework. However, it's important to note that the task operator alone is responsible for making the web API call. To obtain the actual response, an additional action needs to be implemented, which will capture and store the result within a `TaskResult` object. ```swift struct ProductListDomain: ReducerProtocol { // State and more ... enum Action: Equatable { case fetchProducts case fetchProductsResponse(TaskResult<[Product]>) } var fetchProducts: () async throws -> [Product] var uuid: () -> UUID var body: some ReducerProtocol { // Other child reducers... Reduce { state, action in switch action { case .fetchProducts: return .task { // Just making the call await .fetchProductsResponse( TaskResult { try await fetchProducts() } ) } case .fetchProductsResponse(.success(let products)): // Getting the success response state.productListState = IdentifiedArrayOf( uniqueElements: products.map { ProductDomain.State( id: uuid(), product: $0 ) } ) return .none case .fetchProductsResponse(.failure(let error)): // Getting an error from the web API print("Error getting products, try again later.", error) return .none } } } } ``` > To learn more about network requests in TCA, I recommend watching this insightful [video](https://youtu.be/sid-zfggYhQ?list=PLHWvYoDHvsOVo4tklgLW1g7gy4Kmk4kjw&t=144) that explains asynchronous requests. Additionally, you can refer to this informative [video](https://youtu.be/j2qymM6i9n4) that demonstrates the configuration of a real web API call, providing practical insights into the process. ## Navigation Navigation is a huge and complex topic. Navigation are alerts, confirmation dialogs, sheets, popovers and links. Also, you can add a custom navigations if you want. In this project you will see alerts and sheets. ### Alerts The TCA library offers support for `AlertView`, enabling the addition of custom state and a consistent UI building approach without deviating from the TCA architecture. To create your own alert using TCA, follow these steps: 1. Create the alert actions inside of the Action enum of the reducer. The recommended way is create a nested enum inside the action. ```swift enum Action: Equatable { enum Alert { case alertAction1 case alertAction2 .... } } ``` 2. Next, create a case alert and use `PresentationAction`. ```swift enum Action: Equatable { case alert(PresentationAction) case alertButtonTapped enum Alert { case alertAction1 case alertAction2 .... } } ``` `PresentationAction` is a generic that represents the presented actions and an special action named dismiss. This is very useful case because with the dismiss action, the reducer can manage if a side effect is running and remove to the system. More information about effect cancelling in navigations [here](https://www.pointfree.co/collections/composable-architecture/navigation/ep225-composable-navigation-behavior). ```swift public enum PresentationAction { /// An action sent to `nil` out the associated presentation state. case dismiss /// An action sent to the associated, non-`nil` presentation state. indirect case presented(Action) } ``` 3. Create an alert state inside of the reducer. ```swift @Presents var alert: AlertState? ``` `@Presents` is a property wrapper that you need to use when creates a navigation state in the reducer. The reason to use `@Presents` is when composing a lots of features together, the root state could overflow the stack. More information [here](https://www.pointfree.co/collections/composable-architecture/navigation/ep230-composable-navigation-stack-vs-heap). 4. Extent `AlertState` and create as many alerts as you want. You can create a property wrapper or a function if you need some dynamic information. ```swift extension AlertState where Action == CartListDomain.Action.Alert { static var successAlert: AlertState { AlertState { TextState("Thank you!") } actions: { ButtonState(action: .dismissSuccessAlert, label: { TextState("Done") }) ButtonState(role: .cancel, action: .didCancelConfirmation, label: { TextState("Cancel") }) } message: { TextState("Your order is in process.") } } static func confirmationAlert(totalPriceString: String) -> AlertState { AlertState { TextState("Confirm your purchase") } actions: { ButtonState(action: .didConfirmPurchase, label: { TextState("Pay \(totalPriceString)") }) ButtonState(role: .cancel, action: .didCancelConfirmation, label: { TextState("Cancel") }) } message: { TextState("Do you want to proceed with your purchase of \(totalPriceString)?") } } } ``` 5. Inside of the body of the reducer you can set the alert. As the state is an optional value, you need to implement `ifLet` in the reducer. This is a particular modifier that not need a reducer like a tipical `ifLet` reducer. Another question is when you use a reducer for navigation, you will use the binding operator `$` in the state. This is because navigation modifiers in SwiftUI use a binding for presenting, usually the `isPresented` boolean. In this case, in order to manage when the alert is presented or no, you use a binding state in the reducer. Now, the reducer is fully synchronized with the view. ```swift var body: some ReducerOf { Reduce { state, action in switch action { case .alert: return .none case .alertButtonTapped: state.alert = .successAlert return .none } } .ifLet( \.$alert, action: \.alert ) } ``` 6. Finally, implements `AlertView` modifier in the view. Remember, you use the binding `$` operator. ```swift .alert( store: store.scope( state: \.$alert, action: \.alert ) ) ```
See Alerts in previous versions of TCA The TCA library also offers support for `AlertView`, enabling the addition of custom state and a consistent UI building approach without deviating from the TCA architecture. To create your own alert using TCA, follow these steps: 1. Create an `AlertState` with actions of your own domain. 2. Create the actions that will trigger events for the alert: - Initialize AlertState (`didPressPayButton`) - Dismiss the alert (`didCancelConfirmation`) - Execute the alert's handler (`didConfirmPurchase`) ```swift struct CartListDomain: ReducerProtocol { struct State: Equatable { var confirmationAlert: AlertState? // More properties ... } enum Action: Equatable { case didPressPayButton case didCancelConfirmation case didConfirmPurchase // More actions ... } var body: some ReducerProtocol { Reduce { state, action in switch action { case .didCancelConfirmation: state.confirmationAlert = nil return .none case .didConfirmPurchase: // Sent order and Pay ... case .didPressPayButton: state.confirmationAlert = AlertState( title: TextState("Confirm your purchase"), message: TextState("Do you want to proceed with your purchase of \(state.totalPriceString)?"), buttons: [ .default( TextState("Pay \(state.totalPriceString)"), action: .send(.didConfirmPurchase)), .cancel(TextState("Cancel"), action: .send(.didCancelConfirmation)) ] ) return .none // More actions ... } } .forEach(\.cartItems, action: /Action.cartItem(id:action:)) { CartItemDomain() } } } ```
### Sheets Other type of navigation are sheets. To create your own alert using TCA, follow these steps: 1. As the alerts, create the state. You use `@Presents` to avoid accidentally overflow the stack. ```swift @Presents var cartState: CartListDomain.State? ``` 2. Next, create the action. Remember to use PresentationAction inside the case of the sheet. ```swift case cart(PresentationAction) ``` 3. Create the `ifLet` in the reducer. Here, you need to define the reducer of the destination. ```swift .ifLet(\.$cartState, action: \.cart) { CartListDomain() } ``` 4. Finally, in the view, you can define the sheet operator like this. ```swift .sheet( item: $store.scope( state: \.cartState, action: \.cart ) ) { store in CartListView(store: store) } ```
See Sheets in previous versions of TCA ### Opening Modal Views If you require to open a view modally in SwiftUI, you will need to use sheet modifier and provide a binding parameter: ```swift func sheet( isPresented: Binding, onDismiss: (() -> Void)? = nil, @ViewBuilder content: @escaping () -> Content ) -> some View where Content : View ``` To utilize this modifier (or any modifier with binding parameters) in TCA, it is necessary to employ the `binding` operator from `viewStore` and supply two parameters: 1. The state property that will undergo mutation. 2. The action that will trigger the mutation. ```swift // Domain: struct Domain: ReducerProtocol { struct State { var shouldOpenModal = false } enum Action { case setCartView(isPresented: Bool) } var body: some ReducerProtocol { Reduce { state, action in switch action { case .setCartView(let isPresented): state.shouldOpenModal = isPresented } } } } // UI: Text("Parent View") .sheet( isPresented: viewStore.binding( get: \.shouldOpenModal, send: Action.setModalView(isPresented:) ) ) { Text("I'm a Modal View!") } ``` > If you want to lean more about Binding with TCA and SwiftUI, take a look to this [video](https://youtu.be/Ilr8AsoggIY).
## Testing ### Testing Basics Testing is a crucial part of software development. TCA has its own tools to test reducers in a very simple way. When you test a reducer, you will use a TestStore class passing an initial state and a reducer like the store that you are using in the production code. Next, you can send an action but, in this case, send receive a closure that you need to expect the result of this action. For example, when you send increseCounter action, you expect that count is equal to 1 if previously, your state counter is 0. Finally, you send a decreaseCounter and the expectation of this action is count state equal to 0 because previously count was setted to 1. ```swift @MainActor class CounterDomainTest: XCTestCase { func testHappyPath() { let store = TestStore( initialState: CounterDomain.State(), reducer: { CounterDomain() } ) await store.send(.increaseCounter) { $0.count = 1 } await store.send(.decreaseCounter) { $0.count = 0 } } } ``` ### Testing Side effects The first thing is the ability to mock every side effect of the system. To do that TestStore has a closure for this purpose. Notice that `fetchProducts` action has a side effect. When it finishes, send an action `fetchProductsResponse` back to the system. When you test this, you will use `store.receive` for response actions. ```swift @MainActor class ProductListDomainTest: XCTestCase { func testSideEffects() { let products: [Product] = ... let store = TestStore( initialState: ProductListDomain.State(), reducer: { ProductListDomain() } ) { $0.apiClient.fetchProducts = { products } } await store.send(.fetchProducts) { $0.dataLoadingStatus = .loading } await store.receive(.fetchProductsResponse(.success(products))) { $0.products = products $0.dataLoadingStatus = .success } } } ``` ### Testing CasePathable CasePathable is a nice macro that it has a lot of useful tips. One of those is using keypaths for testing actions. For example, if you have this test. ```swift await store.send( .cartItem( .element( id: cartItemId1, action: .deleteCartItem(product: Product.sample[0])) ) ) { ... } ``` We can update this with: ```swift await store.send(\.cartItem[id: cartItemId1].deleteCartItem, Product.sample[0]) { ... } ``` Another example: ```swift await store.send(.alert(.presented(.didConfirmPurchase))) ``` ```swift await store.send(\.alert.didConfirmPurchase) ``` ## Other topics ### Optional States By default, TCA keeps a state in memory throughout the entire lifecycle of an app. However, in certain scenarios, maintaining a state can be resource-intensive and unnecessary. One such case is when dealing with modal views that are displayed for a short duration. In these situations, it is more efficient to use optional states. Creating an optional state in TCA follows the same approach as declaring any optional value in Swift. Simply define the property within the parent state, but instead of assigning a default value, declare it as optional. For instance, in the provided example, the `cartState` property holds an optional state for a Cart List. ```swift struct ProductListDomain: ReducerProtocol { struct State: Equatable { var productListState: IdentifiedArrayOf = [] var shouldOpenCart = false var cartState: CartListDomain.State? // More properties... } } ``` Now, in the `Reduce` function, we can utilize the `ifLet` operator to transform the child reducer (`CartListDomain`) into one that is compatible with the parent reducer (`ProductList`). In the provided example, the `CartListDomain` will be evaluated only if the `cartState` is non-nil. To assign a new non-optional state, the parent reducer will need to initialize the property (`cartState`) when a specific action (`setCartView`) is triggered. This approach ensures that the optional state is properly handled within the TCA framework and allows for seamless state management between the parent and the optional child reducers. ```swift struct ProductListDomain: ReducerProtocol { // State and Actions ... var body: some ReducerProtocol { Reduce { state, action in switch action { // More cases ... case .setCartView(let isPresented): state.shouldOpenCart = isPresented state.cartState = isPresented ? CartListDomain.State(...) : nil return .none } } .ifLet(\.cartState, action: /ProductListDomain.Action.cart) { CartListDomain() } } } ``` Lastly, in the view, you can employ `IfLetStore` to unwrap a store with optional state. This allows you to conditionally display the corresponding view that operates with that particular state. ```swift List { ForEachStore( self.store.scope( state: \.productListState, action: ProductListDomain.Action .product(id: action:) ) ) { ProductCell(store: $0) } } .sheet( isPresented: viewStore.binding( get: \.shouldOpenCart, send: ProductListDomain.Action.setCartView(isPresented:) ) ) { IfLetStore( self.store.scope( state: \.cartState, action: ProductListDomain.Action.cart ) ) { CartListView(store: $0) } } ``` > If you want to learn more about optional states, check out this [video](https://youtu.be/AV0laQw2OjM). ### Private Actions By default, when you declare an action in a TCA domain, it is accessible to other reducers as well. However, there are situations where an action is intended to be specific to a particular reducer and does not need to be exposed outside of it. In such cases, you can simply declare private functions to encapsulate those actions within the domain's scope. This approach ensures that the actions remain private and only accessible within the intended context, enhancing the encapsulation and modularity of your TCA implementation: ```swift var body: some ReducerProtocol // More reducers ... Reduce { state, action in switch action { // More actions ... case .cart(let action): switch action { case .didPressCloseButton: return closeCart(state: &state) case .dismissSuccessAlert: resetProductsToZero(state: &state) return .task { .closeCart } } case .closeCart: return closeCart(state: &state) } } } private func closeCart( state: inout State ) -> Effect { state.shouldOpenCart = false state.cartState = nil return .none } private func resetProductsToZero( state: inout State ) { for id in state.productListState.map(\.id) where state.productListState[id: id]?.count != 0 { state.productListState[id: id]?.addToCartState.count = 0 } } ``` > For more about private actions, check out this [video](https://youtu.be/7BkZX_7z-jw). 3. Invoke the UI ```swift let store: Store Text("Parent View") .alert( self.store.scope(state: \.confirmationAlert, action: { $0 }), dismiss: .didCancelConfirmation ) ``` > Explicit action is always needed for `store.scope`. Check out this commit to learn more: https://github.com/pointfreeco/swift-composable-architecture/commit/da205c71ae72081647dfa1442c811a57181fb990 This [video](https://youtu.be/U3EMduy-DhE) explains more about AlertView in SwiftUI and TCA. ### Making a Root Domain with Tab View Creating a Root Domain in TCA is similar to creating any other domain. In this case, each property within the state will correspond to a complex substate. To handle tab logic, we can include an enum that represents each tab item, providing a structured approach to managing the different tabs: ```swift struct RootDomain: ReducerProtocol { struct State: Equatable { var selectedTab = Tab.products var productListState = ProductListDomain.State() var profileState = ProfileDomain.State() } enum Tab { case products case profile } enum Action: Equatable { case tabSelected(Tab) case productList(ProductListDomain.Action) case profile(ProfileDomain.Action) } // Dependencies var fetchProducts: @Sendable () async throws -> [Product] var sendOrder: @Sendable ([CartItem]) async throws -> String var fetchUserProfile: @Sendable () async throws -> UserProfile var uuid: @Sendable () -> UUID static let live = Self( fetchProducts: APIClient.live.fetchProducts, sendOrder: APIClient.live.sendOrder, fetchUserProfile: APIClient.live.fetchUserProfile, uuid: { UUID() } ) var body: some ReducerProtocol { Reduce { state, action in switch action { case .productList: return .none case .tabSelected(let tab): state.selectedTab = tab return .none case .profile: return .none } } Scope(state: \.productListState, action: /RootDomain.Action.productList) { ProductListDomain( fetchProducts: fetchProducts, sendOrder: sendOrder, uuid: uuid ) } Scope(state: \.profileState, action: /RootDomain.Action.profile) { ProfileDomain(fetchUserProfile: fetchUserProfile) } } } ``` When it comes to the UI implementation, it closely resembles the standard SwiftUI approach, with a small difference. Instead of using a regular property, we hold the `store` property to manage the currently selected tab: ```swift struct RootView: View { let store: Store var body: some View { WithViewStore(self.store) { viewStore in TabView( selection: viewStore.binding( get: \.selectedTab, send: RootDomain.Action.tabSelected ) ) { ProductListView( store: self.store.scope( state: \.productListState, action: RootDomain.Action .productList ) ) .tabItem { Image(systemName: "list.bullet") Text("Products") } .tag(RootDomain.Tab.products) ProfileView( store: self.store.scope( state: \.profileState, action: RootDomain.Action.profile ) ) .tabItem { Image(systemName: "person.fill") Text("Profile") } .tag(RootDomain.Tab.profile) } } } } ``` To call RootView, we provide the initial domain state and the reducer: To instantiate the `RootView`, you need to provide two parameters: the initial domain state and the reducer: ```swift @main struct OnlineStoreTCAApp: App { var body: some Scene { WindowGroup { RootView( store: Store( initialState: RootDomain.State(), reducer: RootDomain.live ) ) } } } ``` These elements enable the proper initialization and functioning of the `RootView` within the TCA architecture. > For a comprehensive understanding of this implementation, I recommend checking out this [video](https://youtu.be/a_FwMVIhCHY). ## Contact If you have any feedback, I would love to hear from you. Please feel free to reach out to me through any of my social media channels: * [Youtube](https://youtube.com/@swiftandtips) * [Twitter](https://twitter.com/swiftandtips) * [LinkedIn](https://www.linkedin.com/in/pedrorojaslo/) * [Mastodon](https://iosdev.space/@swiftandtips) Thanks for reading, and have a great day! 😄