├── OnlineStoreMV ├── Assets.xcassets │ ├── Contents.json │ ├── errorCloud.imageset │ │ ├── errorCloud.png │ │ └── Contents.json │ ├── question.imageset │ │ ├── question-mark-icon-free-vector.jpg │ │ └── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ └── AppIcon.appiconset │ │ └── Contents.json ├── Preview Content │ ├── Preview Assets.xcassets │ │ └── Contents.json │ ├── CartItem+Sample.swift │ └── Product+Sample.swift ├── Logger │ ├── LoggingStrategy.swift │ ├── InMemoryStrategy.swift │ ├── Logger.swift │ └── FileLoggingStrategy.swift ├── Root │ ├── OnlineStoreMVApp.swift │ └── RootView.swift ├── OnlineStoreMV.entitlements ├── Discounts │ ├── DiscountProvider.swift │ └── DiscountCalculator.swift ├── Model │ ├── CartItem.swift │ ├── AccountStore.swift │ ├── UserProfile.swift │ ├── Product.swift │ ├── ProductStore.swift │ └── CartStore.swift ├── Product │ ├── ProductErrorView.swift │ ├── ProductCell.swift │ └── ProductList.swift ├── Database │ └── DatabaseClient.swift ├── AddToCart │ ├── AddToCartButton.swift │ └── PlusMinusButton.swift ├── Profile │ └── ProfileView.swift ├── Cart │ ├── CartCell.swift │ └── CartListView.swift ├── Network │ └── APIClient.swift └── Test │ ├── UI │ └── OnlineStoreMV_UITests.swift │ ├── ProductStoreTest.swift │ └── CartStoreTest.swift ├── OnlineStoreMV.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved ├── xcuserdata │ └── projas.xcuserdatad │ │ ├── xcdebugger │ │ └── Breakpoints_v2.xcbkptlist │ │ └── xcschemes │ │ └── xcschememanagement.plist ├── xcshareddata │ └── xcschemes │ │ └── OnlineStoreMV.xcscheme └── project.pbxproj └── README.md /OnlineStoreMV/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /OnlineStoreMV/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /OnlineStoreMV/Assets.xcassets/errorCloud.imageset/errorCloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pitt500/OnlineStoreMV/HEAD/OnlineStoreMV/Assets.xcassets/errorCloud.imageset/errorCloud.png -------------------------------------------------------------------------------- /OnlineStoreMV/Assets.xcassets/question.imageset/question-mark-icon-free-vector.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/pitt500/OnlineStoreMV/HEAD/OnlineStoreMV/Assets.xcassets/question.imageset/question-mark-icon-free-vector.jpg -------------------------------------------------------------------------------- /OnlineStoreMV.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /OnlineStoreMV/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /OnlineStoreMV.xcodeproj/xcuserdata/projas.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist: -------------------------------------------------------------------------------- 1 | 2 | 6 | 7 | -------------------------------------------------------------------------------- /OnlineStoreMV/Logger/LoggingStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LoggingStrategy.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 16/09/24. 6 | // 7 | 8 | 9 | protocol LoggingStrategy { 10 | func log(_ message: String) 11 | func clear() 12 | var loggedMessages: [String] { get } 13 | } 14 | -------------------------------------------------------------------------------- /OnlineStoreMV.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /OnlineStoreMV/Preview Content/CartItem+Sample.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CartItem+Sample.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 04/05/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension CartItem { 11 | static let sample = Product.sample.map { 12 | CartItem(product: $0, quantity: 2) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /OnlineStoreMV/Root/OnlineStoreMVApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnlineStoreMVApp.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 04/03/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct OnlineStoreMVApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | RootView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /OnlineStoreMV/OnlineStoreMV.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /OnlineStoreMV/Assets.xcassets/errorCloud.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "errorCloud.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /OnlineStoreMV/Assets.xcassets/question.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "question-mark-icon-free-vector.jpg", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "idiom" : "universal", 10 | "scale" : "2x" 11 | }, 12 | { 13 | "idiom" : "universal", 14 | "scale" : "3x" 15 | } 16 | ], 17 | "info" : { 18 | "author" : "xcode", 19 | "version" : 1 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /OnlineStoreMV/Logger/InMemoryStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InMemoryStrategy.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 16/09/24. 6 | // 7 | 8 | class InMemoryStrategy: LoggingStrategy { 9 | private var logs: [String] = [] 10 | 11 | func log(_ message: String) { 12 | logs.append(message) 13 | print("[InMemoryStrategy]: \(message)") 14 | } 15 | 16 | func clear() { 17 | logs.removeAll() 18 | print("[InMemoryStrategy] Cleared all log messages from memory.") 19 | } 20 | 21 | var loggedMessages: [String] { 22 | logs 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /OnlineStoreMV/Discounts/DiscountProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiscountProvider.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 16/09/24. 6 | // 7 | 8 | 9 | struct DiscountProvider { 10 | private let discounts: [Int: Double] 11 | 12 | init(discounts: [Int: Double]) { 13 | self.discounts = discounts 14 | } 15 | 16 | func getDiscount(for productID: Int) -> Double? { 17 | return discounts[productID] 18 | } 19 | } 20 | 21 | extension DiscountProvider { 22 | // Missing live implementation. This is temporary. 23 | static let live = DiscountProvider(discounts: [1: 0.1, 2: 0.2]) 24 | static let empty = DiscountProvider(discounts: [:]) 25 | } 26 | -------------------------------------------------------------------------------- /OnlineStoreMV/Model/CartItem.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CartItem.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 05/03/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct CartItem: Equatable, Identifiable { 11 | let product: Product 12 | var quantity: Int 13 | 14 | var id = UUID() 15 | } 16 | 17 | extension CartItem: Encodable { 18 | private enum CartItemsKey: String, CodingKey { 19 | case productId 20 | case quantity 21 | } 22 | 23 | func encode(to encoder: Encoder) throws { 24 | var container = encoder.container(keyedBy: CartItemsKey.self) 25 | try container.encode(product.id, forKey: .productId) 26 | try container.encode(quantity, forKey: .quantity) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /OnlineStoreMV.xcodeproj/xcuserdata/projas.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | OnlineStoreMV.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 0 11 | 12 | 13 | SuppressBuildableAutocreation 14 | 15 | B559E6AC2B9AF88D002218EB 16 | 17 | primary 18 | 19 | 20 | B58F25E32B96E58E000CAD57 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /OnlineStoreMV/Product/ProductErrorView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductErrorView.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 01/05/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProductErrorView: View { 11 | let message: String 12 | 13 | var body: some View { 14 | let _ = print(message) 15 | VStack { 16 | Image("errorCloud") 17 | .resizable() 18 | .frame(width: 200, height: 200) 19 | Text("There was a problem reaching the server. Please try again later.") 20 | .font(/*@START_MENU_TOKEN@*/.title/*@END_MENU_TOKEN@*/) 21 | .padding(20) 22 | .multilineTextAlignment(.center) 23 | 24 | } 25 | 26 | } 27 | } 28 | 29 | #Preview { 30 | ProductErrorView(message: "Error") 31 | } 32 | -------------------------------------------------------------------------------- /OnlineStoreMV.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "501e1b980947b251f83b154bb9736d5b43180cfa9093f7c42126932751f600a3", 3 | "pins" : [ 4 | { 5 | "identity" : "swift-syntax", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/swiftlang/swift-syntax.git", 8 | "state" : { 9 | "revision" : "515f79b522918f83483068d99c68daeb5116342d", 10 | "version" : "600.0.0-prerelease-2024-09-04" 11 | } 12 | }, 13 | { 14 | "identity" : "swift-testing", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/apple/swift-testing", 17 | "state" : { 18 | "revision" : "c55848b2aa4b29a4df542b235dfdd792a6fbe341", 19 | "version" : "0.12.0" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /OnlineStoreMV/Database/DatabaseClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DatabaseClient.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 12/09/24. 6 | // 7 | 8 | struct DatabaseClient { 9 | var saveProducts: ([Product]) -> Void 10 | var fetchCachedProducts: () -> [Product] 11 | var clear: () -> Void 12 | } 13 | 14 | extension DatabaseClient { 15 | static let inMemory = Self( 16 | saveProducts: { products in 17 | MockedDatabase.shared.cachedProducts = products 18 | }, 19 | fetchCachedProducts: { 20 | MockedDatabase.shared.cachedProducts 21 | }, 22 | clear: { 23 | MockedDatabase.shared.cachedProducts.removeAll() 24 | } 25 | ) 26 | 27 | // Missing live implementation. This is temporary 28 | static let live = DatabaseClient.inMemory 29 | } 30 | 31 | // Simple in-memory database for caching 32 | class MockedDatabase { 33 | static let shared = MockedDatabase() 34 | var cachedProducts: [Product] = [] 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OnlineStore made with SwiftUI (Vanilla) and Observation 2 | 3 | ## Motivation 4 | 5 | Please check out this [article](https://swiftandtips.com/is-mvvm-necessary-for-developing-apps-with-swiftui), where I explain in detail the reasons behind creating this demo. 6 | 7 | ## Observation 8 | For more information about implementing observation pattern in SwiftUI, check out this [article](https://swiftandtips.com/implementing-observation-in-swiftui). 9 | 10 | ## Testing 11 | This project was used to demonstrate the concept of unit, integration, and UI Tests. If you want to learn more, check out this [playlist](https://www.youtube.com/playlist?list=PLHWvYoDHvsOXQHJ0rNiXShZLcKBOIOePH) 12 | 13 | ### Swift Testing Framework 14 | In WWDC24, Apple launched a new testing framework to eventually replace XCTest, called "Swift Testing". I also used this project to demonstrate the power of Swift Testing. Go check out [this playlist](https://www.youtube.com/playlist?list=PLHWvYoDHvsOV67md_mU5nMN_HDZK7rEKn) if you want to learn more. 15 | -------------------------------------------------------------------------------- /OnlineStoreMV/AddToCart/AddToCartButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddToCartButton.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 05/03/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AddToCartButton: View { 11 | let product: Product 12 | @Environment(CartStore.self) private var cartStore 13 | 14 | var body: some View { 15 | if cartStore.quantity(for: product) > 0 { 16 | PlusMinusButton(product: product) 17 | } else { 18 | Button { 19 | cartStore.addToCart(product: product) 20 | } label: { 21 | Text("Add to Cart") 22 | .padding(10) 23 | .background(.blue) 24 | .foregroundColor(.white) 25 | .cornerRadius(10) 26 | } 27 | .buttonStyle(.plain) 28 | } 29 | } 30 | } 31 | 32 | #if DEBUG 33 | #Preview { 34 | AddToCartButton(product: Product.sample.first!) 35 | .environment(CartStore(logger: .inMemory)) 36 | } 37 | #endif 38 | -------------------------------------------------------------------------------- /OnlineStoreMV/Discounts/DiscountCalculator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DiscountCalculator.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 16/09/24. 6 | // 7 | 8 | struct DiscountCalculator { 9 | let discountProvider: DiscountProvider 10 | 11 | func applyDiscountIfEligible(to product: Product) -> Product { 12 | var discountedProduct = product 13 | 14 | if let discountPercentage = discountProvider.getDiscount(for: product.id) { 15 | // Validate that the discount percentage is between 0.0 and 1.0 16 | guard discountPercentage >= 0.0 && discountPercentage <= 1.0 else { 17 | print("Error: Discount percentage for product \(product.id) is out of range") 18 | return product 19 | } 20 | 21 | discountedProduct.percentageDiscount = discountPercentage 22 | } 23 | 24 | return discountedProduct 25 | } 26 | 27 | func applyDiscount(to products: [Product]) -> [Product] { 28 | return products.map { applyDiscountIfEligible(to: $0) } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /OnlineStoreMV/Model/AccountStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccountStore.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 07/03/24. 6 | // 7 | 8 | import Foundation 9 | 10 | @Observable 11 | class AccountStore { 12 | enum LoadingState { 13 | case notStarted 14 | case loading 15 | case loaded(user: UserProfile) 16 | case error(message: String) 17 | } 18 | 19 | private var user: UserProfile? 20 | private let apiClient: APIClient 21 | var loadingState = LoadingState.notStarted 22 | 23 | 24 | init(apiClient: APIClient = .live) { 25 | self.apiClient = apiClient 26 | } 27 | 28 | func fetchUserProfile() async { 29 | if let user = self.user { 30 | loadingState = .loaded(user: user) 31 | return 32 | } 33 | 34 | do { 35 | loadingState = .loading 36 | let user = try await apiClient.fetchUserProfile() 37 | self.user = user 38 | loadingState = .loaded(user: user) 39 | } catch { 40 | loadingState = .error(message: error.localizedDescription) 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /OnlineStoreMV/Logger/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 16/09/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class Logger { 11 | private var strategy: LoggingStrategy 12 | 13 | init(strategy: LoggingStrategy) { 14 | self.strategy = strategy 15 | } 16 | 17 | func setStrategy(_ strategy: LoggingStrategy) { 18 | self.strategy = strategy 19 | } 20 | 21 | func log(_ message: String) { 22 | strategy.log(message) 23 | } 24 | 25 | var loggedMessages: [String] { 26 | strategy.loggedMessages 27 | } 28 | 29 | func clear() { 30 | strategy.clear() 31 | } 32 | } 33 | 34 | extension Logger { 35 | 36 | static var inMemory: Logger { 37 | return Logger(strategy: InMemoryStrategy()) 38 | } 39 | 40 | static func fileLogging(fileName: String = "onlineStoreApp.log") -> Logger { 41 | let fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 42 | .appendingPathComponent(fileName) 43 | return fileLogging(fileURL: fileURL) 44 | } 45 | 46 | static func fileLogging(fileURL: URL) -> Logger { 47 | return Logger(strategy: FileLoggingStrategy(fileURL: fileURL)) 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /OnlineStoreMV/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "idiom" : "mac", 10 | "scale" : "1x", 11 | "size" : "16x16" 12 | }, 13 | { 14 | "idiom" : "mac", 15 | "scale" : "2x", 16 | "size" : "16x16" 17 | }, 18 | { 19 | "idiom" : "mac", 20 | "scale" : "1x", 21 | "size" : "32x32" 22 | }, 23 | { 24 | "idiom" : "mac", 25 | "scale" : "2x", 26 | "size" : "32x32" 27 | }, 28 | { 29 | "idiom" : "mac", 30 | "scale" : "1x", 31 | "size" : "128x128" 32 | }, 33 | { 34 | "idiom" : "mac", 35 | "scale" : "2x", 36 | "size" : "128x128" 37 | }, 38 | { 39 | "idiom" : "mac", 40 | "scale" : "1x", 41 | "size" : "256x256" 42 | }, 43 | { 44 | "idiom" : "mac", 45 | "scale" : "2x", 46 | "size" : "256x256" 47 | }, 48 | { 49 | "idiom" : "mac", 50 | "scale" : "1x", 51 | "size" : "512x512" 52 | }, 53 | { 54 | "idiom" : "mac", 55 | "scale" : "2x", 56 | "size" : "512x512" 57 | } 58 | ], 59 | "info" : { 60 | "author" : "xcode", 61 | "version" : 1 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /OnlineStoreMV/Model/UserProfile.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UserProfile.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 07/03/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct UserProfile: Equatable { 11 | let id: Int 12 | let email: String 13 | let firstName: String 14 | let lastName: String 15 | } 16 | 17 | extension UserProfile: Decodable { 18 | private enum ProfileKeys: String, CodingKey { 19 | case id 20 | case email 21 | case name 22 | case firstname 23 | case lastname 24 | } 25 | 26 | init(from decoder: Decoder) throws { 27 | let container = try decoder.container(keyedBy: ProfileKeys.self) 28 | self.id = try container.decode(Int.self, forKey: .id) 29 | self.email = try container.decode(String.self, forKey: .email) 30 | 31 | let nameContainer = try container.nestedContainer(keyedBy: ProfileKeys.self, forKey: .name) 32 | self.firstName = try nameContainer.decode(String.self, forKey: .firstname) 33 | self.lastName = try nameContainer.decode(String.self, forKey: .lastname) 34 | } 35 | } 36 | 37 | extension UserProfile { 38 | static var sample: UserProfile { 39 | .init( 40 | id: 1, 41 | email: "hello@demo.com", 42 | firstName: "Pedro", 43 | lastName: "Rojas" 44 | ) 45 | } 46 | } 47 | 48 | -------------------------------------------------------------------------------- /OnlineStoreMV/AddToCart/PlusMinusButton.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PlusMinusButton.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 05/03/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PlusMinusButton: View { 11 | let product: Product 12 | @Environment(CartStore.self) private var cartStore 13 | 14 | var body: some View { 15 | HStack { 16 | Button { 17 | cartStore.removeFromCart(product: product) 18 | } label: { 19 | Text("-") 20 | .padding(10) 21 | .background(.blue) 22 | .foregroundColor(.white) 23 | .cornerRadius(10) 24 | } 25 | .buttonStyle(.plain) 26 | 27 | Text(cartStore.quantity(for: product).description) 28 | .accessibilityIdentifier("productQuantity") 29 | .padding(5) 30 | 31 | Button { 32 | cartStore.addToCart(product: product) 33 | } label: { 34 | Text("+") 35 | .padding(10) 36 | .background(.blue) 37 | .foregroundColor(.white) 38 | .cornerRadius(10) 39 | } 40 | .buttonStyle(.plain) 41 | } 42 | } 43 | } 44 | 45 | #if DEBUG 46 | #Preview { 47 | PlusMinusButton(product: Product.sample.first!) 48 | .environment(CartStore(logger: .inMemory)) 49 | 50 | } 51 | #endif 52 | -------------------------------------------------------------------------------- /OnlineStoreMV/Model/Product.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Product.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 04/03/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Product: Equatable, Identifiable { 11 | let id: Int 12 | let title: String 13 | var price: Double // Update to Currency 14 | let description: String 15 | let category: String // Update to enum 16 | let imageURL: URL 17 | var percentageDiscount: Double? // Discount percentage (0.0 to 1.0), nil if no discount 18 | 19 | var hasDiscount: Bool { 20 | return percentageDiscount != nil 21 | } 22 | 23 | var discountedPrice: Double { 24 | guard let discount = percentageDiscount else { 25 | return price 26 | } 27 | return price * (1 - discount) 28 | } 29 | 30 | // Add rating later... 31 | } 32 | 33 | extension Product: Decodable { 34 | enum ProductKeys: String, CodingKey { 35 | case id 36 | case title 37 | case price 38 | case description 39 | case category 40 | case image 41 | } 42 | 43 | init(from decoder: Decoder) throws { 44 | let container = try decoder.container(keyedBy: ProductKeys.self) 45 | self.id = try container.decode(Int.self, forKey: .id) 46 | self.title = try container.decode(String.self, forKey: .title) 47 | self.price = try container.decode(Double.self, forKey: .price) 48 | self.description = try container.decode(String.self, forKey: .description) 49 | self.category = try container.decode(String.self, forKey: .category) 50 | let imageString = try container.decode(String.self, forKey: .image) 51 | 52 | self.imageURL = URL(string: imageString)! 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /OnlineStoreMV/Profile/ProfileView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProfileView.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 07/03/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProfileView: View { 11 | @Environment(AccountStore.self) var accountStore 12 | 13 | var body: some View { 14 | NavigationView { 15 | ZStack { 16 | switch accountStore.loadingState { 17 | case .loading, .notStarted: 18 | ProgressView() 19 | .task { 20 | await accountStore.fetchUserProfile() 21 | } 22 | case .error: 23 | VStack { 24 | Text("Unable to load user profile") 25 | Button { 26 | Task { 27 | await accountStore.fetchUserProfile() 28 | } 29 | } label: { 30 | Text("Retry") 31 | } 32 | } 33 | case .loaded(let user): 34 | Form { 35 | Section { 36 | Text(user.firstName.capitalized) 37 | + 38 | Text(" \(user.lastName.capitalized)") 39 | } header: { 40 | Text("Full name") 41 | } 42 | 43 | Section { 44 | Text(user.email) 45 | } header: { 46 | Text("Email") 47 | } 48 | } 49 | } 50 | } 51 | .navigationTitle("Profile") 52 | } 53 | 54 | } 55 | } 56 | 57 | #if DEBUG 58 | #Preview { 59 | ProfileView() 60 | .environment(AccountStore(apiClient: .testSuccess)) 61 | } 62 | #endif 63 | -------------------------------------------------------------------------------- /OnlineStoreMV/Logger/FileLoggingStrategy.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FileLoggingStrategy.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 16/09/24. 6 | // 7 | 8 | import Foundation 9 | 10 | class FileLoggingStrategy: LoggingStrategy { 11 | private let fileURL: URL 12 | 13 | init(fileURL: URL) { 14 | self.fileURL = fileURL 15 | } 16 | 17 | init(fileName: String) { 18 | self.fileURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] 19 | .appendingPathComponent(fileName) 20 | } 21 | 22 | func log(_ message: String) { 23 | do { 24 | let data = (message + "\n").data(using: .utf8)! 25 | if FileManager.default.fileExists(atPath: fileURL.path) { 26 | let fileHandle = try FileHandle(forWritingTo: fileURL) 27 | fileHandle.seekToEndOfFile() 28 | fileHandle.write(data) 29 | fileHandle.closeFile() 30 | } else { 31 | try data.write(to: fileURL) 32 | } 33 | print("[FileLoggingStrategy]: \(message)") 34 | } catch { 35 | print("Failed to log message to file: \(error.localizedDescription)") 36 | } 37 | } 38 | 39 | var loggedMessages: [String] { 40 | do { 41 | let data = try Data(contentsOf: fileURL) 42 | let content = String(data: data, encoding: .utf8) 43 | return content?.components(separatedBy: "\n").filter { !$0.isEmpty } ?? [] 44 | } catch { 45 | print("Failed to read log messages from file: \(error.localizedDescription)") 46 | return [] 47 | } 48 | } 49 | 50 | func clear() { 51 | do { 52 | try "".write(to: fileURL, atomically: true, encoding: .utf8) 53 | print("[FileLoggingStrategy] Cleared all log messages from file.") 54 | } catch { 55 | print("Failed to clear log messages: \(error.localizedDescription)") 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /OnlineStoreMV/Root/RootView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RootView.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 07/03/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct RootView: View { 11 | @State private var cartStore = CartStore( 12 | apiClient: apiClient(), 13 | logger: .fileLogging() 14 | ) 15 | @State private var productStore = ProductStore( 16 | apiClient: apiClient(), 17 | discountCalculator: .init(discountProvider: .live), 18 | logger: .fileLogging() 19 | ) 20 | @State private var accountStore = AccountStore() 21 | 22 | var body: some View { 23 | TabView { 24 | ProductList() 25 | .environment(productStore) 26 | .environment(cartStore) 27 | .tabItem { 28 | Image(systemName: "list.bullet") 29 | Text("Products") 30 | } 31 | ProfileView() 32 | .environment(accountStore) 33 | .tabItem { 34 | Image(systemName: "person.fill") 35 | Text("Profile") 36 | } 37 | } 38 | } 39 | } 40 | 41 | #Preview { 42 | RootView() 43 | } 44 | 45 | extension RootView { 46 | private static var isUITestRunning: Bool { 47 | ProcessInfo.processInfo.environment["UI_Testing_Enabled"] == "YES" 48 | } 49 | 50 | private static func apiClient() -> APIClient { 51 | guard isUITestRunning else { return .live } 52 | 53 | enum UITestMode { 54 | case success 55 | case failure 56 | } 57 | 58 | func uiTestMode() -> UITestMode { 59 | if ProcessInfo.processInfo.environment["Test_Mode"] == "FAILURE" { 60 | .failure 61 | } else { 62 | .success 63 | } 64 | } 65 | 66 | switch uiTestMode() { 67 | case .success: 68 | return .uiTestSuccess 69 | case .failure: 70 | return .uiTestFailure 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /OnlineStoreMV/Product/ProductCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductCell.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 04/03/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProductCell: View { 11 | let product: Product 12 | @Environment(CartStore.self) private var cartStore 13 | 14 | var body: some View { 15 | VStack { 16 | AsyncImage( 17 | url: product.imageURL 18 | ) { image in 19 | image 20 | .resizable() 21 | .aspectRatio(contentMode: .fit) 22 | .frame(height: 300) 23 | } placeholder: { 24 | ProgressView() 25 | .frame(height: 300) 26 | } 27 | 28 | VStack(alignment: .leading) { 29 | Text(product.title) 30 | HStack { 31 | if product.hasDiscount { 32 | VStack { 33 | // Show original price struck through 34 | Text("$\(String(format: "%.2f", product.price))") 35 | .font(.custom("AmericanTypewriter", size: 16)) 36 | .strikethrough() 37 | .foregroundColor(.gray) 38 | 39 | // Show discounted price in bold 40 | Text("$\(String(format: "%.2f", product.discountedPrice))") 41 | .fontWeight(.bold) 42 | .foregroundColor(.red) 43 | } 44 | } else { 45 | // If no discount, show normal price 46 | Text("$\(String(format: "%.2f", product.price))") 47 | .fontWeight(.bold) 48 | } 49 | 50 | Spacer() 51 | AddToCartButton(product: product) 52 | } 53 | } 54 | .font(.custom("AmericanTypewriter", size: 20)) 55 | } 56 | .padding(20) 57 | } 58 | } 59 | 60 | #if DEBUG 61 | #Preview { 62 | ProductCell(product: Product.sample.first!) 63 | .environment(CartStore(logger: .inMemory)) 64 | } 65 | #endif 66 | -------------------------------------------------------------------------------- /OnlineStoreMV/Model/ProductStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductStore.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 05/03/24. 6 | // 7 | 8 | import Foundation 9 | 10 | @Observable 11 | class ProductStore { 12 | var products: [Product] 13 | var loadingState: LoadingState = .notStarted 14 | 15 | private let apiClient: APIClient 16 | private let databaseClient: DatabaseClient 17 | private let discountCalculator: DiscountCalculator 18 | private let logger: Logger 19 | 20 | init( 21 | apiClient: APIClient = .live, 22 | databaseClient: DatabaseClient = .live, 23 | discountCalculator: DiscountCalculator, 24 | logger: Logger 25 | ) { 26 | self.products = [] 27 | self.apiClient = apiClient 28 | self.databaseClient = databaseClient 29 | self.discountCalculator = discountCalculator 30 | self.logger = logger 31 | } 32 | 33 | enum LoadingState { 34 | case notStarted 35 | case loading 36 | case loaded(result: [Product]) 37 | case empty 38 | case error(message: String) 39 | } 40 | 41 | @MainActor 42 | func fetchProducts() async { 43 | loadingState = .loading 44 | logger.log("Started fetching products.") 45 | 46 | // Try fetching from the cache first 47 | let cachedProducts = databaseClient.fetchCachedProducts() 48 | guard cachedProducts.isEmpty else { 49 | products = cachedProducts 50 | loadingState = .loaded(result: cachedProducts) 51 | logger.log("Fetched products from cache.") 52 | return 53 | } 54 | 55 | do { 56 | var fetchedProducts = try await apiClient.fetchProducts() 57 | fetchedProducts = discountCalculator.applyDiscount(to: fetchedProducts) 58 | products = fetchedProducts 59 | loadingState = products.isEmpty ? .empty : .loaded(result: products) 60 | 61 | // Save fetched products to the database 62 | databaseClient.saveProducts(products) 63 | logger.log("Fetched products from API and applied discounts.") 64 | } catch { 65 | loadingState = .error(message: error.localizedDescription) 66 | logger.log("Error fetching products: \(error.localizedDescription).") 67 | } 68 | } 69 | 70 | // This code is for demo purpuses 71 | func fetchProducts(completion: @escaping (Result<[Product], Error>) -> Void) { 72 | apiClient.fetchProducts(completion: completion) 73 | } 74 | 75 | } 76 | -------------------------------------------------------------------------------- /OnlineStoreMV/Cart/CartCell.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CartCell.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 06/03/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CartCell: View { 11 | let cartItem: CartItem 12 | @Environment(CartStore.self) private var cartStore 13 | 14 | var body: some View { 15 | VStack { 16 | HStack { 17 | AsyncImage( 18 | url: cartItem.product.imageURL 19 | ) { 20 | $0 21 | .resizable() 22 | .aspectRatio(contentMode: .fit) 23 | .frame(width: 100, height: 100) 24 | } placeholder: { 25 | ProgressView() 26 | .frame(width: 100, height: 100) 27 | } 28 | VStack(alignment: .leading) { 29 | Text(cartItem.product.title) 30 | .lineLimit(3) 31 | .minimumScaleFactor(0.5) 32 | 33 | // Check if the product has a discount 34 | if cartItem.product.hasDiscount { 35 | // Show original price with strikethrough 36 | Text("$\(String(format: "%.2f", cartItem.product.price))") 37 | .font(.custom("AmericanTypewriter", size: 16)) 38 | .strikethrough() 39 | .foregroundColor(.gray) 40 | 41 | // Show discounted price 42 | Text("$\(String(format: "%.2f", cartItem.product.discountedPrice))") 43 | .fontWeight(.bold) 44 | .foregroundColor(.red) 45 | } else { 46 | // Show normal price if no discount 47 | Text("$\(String(format: "%.2f", cartItem.product.price))") 48 | .fontWeight(.bold) 49 | } 50 | } 51 | 52 | } 53 | ZStack { 54 | Group { 55 | Text("Quantity: ") 56 | + 57 | Text("\(cartItem.quantity)") 58 | .fontWeight(.bold) 59 | } 60 | .font(.custom("AmericanTypewriter", size: 25)) 61 | HStack { 62 | Spacer() 63 | Button { 64 | cartStore.removeAllFromCart( 65 | product: cartItem.product 66 | ) 67 | } label: { 68 | Image(systemName: "trash.fill") 69 | .foregroundColor(.red) 70 | .padding() 71 | } 72 | } 73 | } 74 | } 75 | .font(.custom("AmericanTypewriter", size: 20)) 76 | .padding([.bottom, .top], 10) 77 | } 78 | } 79 | 80 | #if DEBUG 81 | #Preview { 82 | CartCell( 83 | cartItem: CartItem( 84 | product: Product.sample.first!, 85 | quantity: 2 86 | ) 87 | ) 88 | .environment(CartStore(logger: .inMemory)) 89 | } 90 | #endif 91 | -------------------------------------------------------------------------------- /OnlineStoreMV/Model/CartStore.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CartStore.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 05/03/24. 6 | // 7 | 8 | import Foundation 9 | 10 | @Observable 11 | class CartStore { 12 | enum SendOrderStatus: Equatable { 13 | case notStarted 14 | case loading 15 | case success 16 | case error 17 | } 18 | 19 | var cartItems: [CartItem] = [] 20 | private let apiClient: APIClient 21 | private let logger: Logger 22 | var sendOrderStatus = SendOrderStatus.notStarted 23 | 24 | init( 25 | cartItems: [CartItem] = [], 26 | apiClient: APIClient = .live, 27 | logger: Logger 28 | ) { 29 | self.cartItems = cartItems 30 | self.apiClient = apiClient 31 | self.logger = logger 32 | } 33 | 34 | var isSendingOrder: Bool { 35 | sendOrderStatus == .loading 36 | } 37 | 38 | func addToCart(product: Product) { 39 | if let index = cartItems.firstIndex( 40 | where: { $0.product.id == product.id } 41 | ) { 42 | cartItems[index].quantity += 1 43 | logger.log("Increased quantity of \(product.title) to \(cartItems[index].quantity).") 44 | } else { 45 | cartItems.append( 46 | CartItem( 47 | product: product, 48 | quantity: 1 49 | ) 50 | ) 51 | logger.log("Added \(product.title) to cart.") 52 | } 53 | } 54 | 55 | func removeFromCart(product: Product) { 56 | guard let index = cartItems.firstIndex( 57 | where: { $0.product.id == product.id } 58 | ) else { return } 59 | 60 | 61 | if cartItems[index].quantity > 1 { 62 | cartItems[index].quantity -= 1 63 | logger.log("Decreased quantity of \(product.title) to \(cartItems[index].quantity).") 64 | } else { 65 | cartItems.remove(at: index) 66 | logger.log("Removed \(product.title) from cart.") 67 | } 68 | } 69 | 70 | func removeAllFromCart(product: Product) { 71 | guard let index = cartItems.firstIndex( 72 | where: { $0.product.id == product.id } 73 | ) else { return } 74 | 75 | cartItems.remove(at: index) 76 | logger.log("Removed all items of \(product.title) from cart.") 77 | } 78 | 79 | func removeAllItems() { 80 | cartItems.removeAll() 81 | logger.log("Removed all items from cart.") 82 | } 83 | 84 | func totalAmount() -> Double { 85 | let total = cartItems.reduce(0.0) { total, cartItem in 86 | total + (cartItem.product.discountedPrice * Double(cartItem.quantity)) 87 | } 88 | logger.log("Calculated total amount: \(total).") 89 | return total 90 | } 91 | 92 | var totalPriceString: String { 93 | let roundedValue = round(totalAmount() * 100) / 100.0 94 | logger.log("Formatted total price: \(roundedValue).") 95 | return "$\(roundedValue)" 96 | } 97 | 98 | func quantity(for product: Product) -> Int { 99 | let quantity = cartItems.first { $0.product.id == product.id }?.quantity ?? 0 100 | logger.log("Retrieved quantity for \(product.title): \(quantity).") 101 | return quantity 102 | } 103 | 104 | func sendOrder() async { 105 | do { 106 | sendOrderStatus = .loading 107 | logger.log("Started sending order.") 108 | 109 | _ = try await apiClient.sendOrder(cartItems) 110 | 111 | sendOrderStatus = .success 112 | logger.log("Order sent successfully.") 113 | } catch { 114 | sendOrderStatus = .error 115 | logger.log("Error sending order: \(error.localizedDescription).") 116 | print("Error sending order: \(error.localizedDescription)") 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /OnlineStoreMV.xcodeproj/xcshareddata/xcschemes/OnlineStoreMV.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 36 | 42 | 43 | 44 | 47 | 53 | 54 | 55 | 56 | 57 | 67 | 69 | 75 | 76 | 77 | 78 | 84 | 86 | 92 | 93 | 94 | 95 | 97 | 98 | 101 | 102 | 103 | -------------------------------------------------------------------------------- /OnlineStoreMV/Product/ProductList.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductList.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 05/03/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ProductList: View { 11 | @Environment(ProductStore.self) private var productStore 12 | @Environment(CartStore.self) private var cartStore 13 | @State private var shouldOpenCart = false 14 | 15 | var body: some View { 16 | NavigationView { 17 | Group { 18 | switch productStore.loadingState { 19 | case .loading, .notStarted: 20 | ProgressView() 21 | .frame(width: 300, height: 300) 22 | .accessibilityIdentifier("progressViewProductList") 23 | .task { 24 | await productStore.fetchProducts() 25 | } 26 | case .error(let message): 27 | ContentUnavailableView( 28 | "There was a problem reaching the server. Please try again later.", 29 | image: "errorCloud", 30 | description: Text(message) 31 | ) 32 | case .empty: 33 | ContentUnavailableView { 34 | Label{ 35 | Text("No Products Found") 36 | } icon: { 37 | Image("question") 38 | .resizable() 39 | .frame(width: 100, height: 100) 40 | } 41 | } description : { 42 | Text("More products will come soon") 43 | } actions: { 44 | Button { 45 | Task { 46 | await productStore.fetchProducts() 47 | } 48 | } label: { 49 | Text("Retry") 50 | .font(.title) 51 | } 52 | } 53 | case .loaded(let products): 54 | List(products) { product in 55 | ProductCell( 56 | product: product 57 | ) 58 | .environment(cartStore) 59 | } 60 | .accessibilityIdentifier("productList") 61 | .refreshable { 62 | await productStore.fetchProducts() 63 | } 64 | } 65 | } 66 | .navigationTitle("Products") 67 | .toolbar { 68 | ToolbarItem(placement: .navigationBarTrailing) { 69 | Button { 70 | shouldOpenCart = true 71 | } label: { 72 | Text("Go to Cart") 73 | } 74 | .accessibilityIdentifier("goToCartButton") 75 | } 76 | } 77 | .sheet(isPresented: $shouldOpenCart) { 78 | CartListView() 79 | } 80 | } 81 | 82 | } 83 | } 84 | 85 | #if DEBUG 86 | #Preview("Happy Path") { 87 | ProductList() 88 | .environment( 89 | ProductStore( 90 | apiClient: .testSuccess, 91 | databaseClient: .inMemory, 92 | discountCalculator: .init(discountProvider: .live), 93 | logger: .inMemory 94 | ) 95 | ) 96 | .environment(CartStore(logger: .inMemory)) 97 | } 98 | 99 | 100 | #Preview("Empty List") { 101 | ProductList() 102 | .environment( 103 | ProductStore( 104 | apiClient: .testEmpty, 105 | databaseClient: .inMemory, 106 | discountCalculator: .init(discountProvider: .live), 107 | logger: .inMemory 108 | ) 109 | ) 110 | .environment(CartStore(logger: .inMemory)) 111 | } 112 | 113 | #Preview("Error from API") { 114 | ProductList() 115 | .environment( 116 | ProductStore( 117 | apiClient: .testError, 118 | databaseClient: .inMemory, 119 | discountCalculator: .init(discountProvider: .live), 120 | logger: .inMemory 121 | ) 122 | ) 123 | .environment(CartStore(logger: .inMemory)) 124 | } 125 | #endif 126 | -------------------------------------------------------------------------------- /OnlineStoreMV/Preview Content/Product+Sample.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Product+Sample.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 04/05/24. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Product { 11 | static var sample: [Product] { 12 | [ 13 | Product( 14 | id: 1, 15 | title: "T-shirt", 16 | price: 20, 17 | description: "This is a description of a cool T-shirt", 18 | category: "SomeArticle", 19 | imageURL: URL(string: "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg")!, 20 | percentageDiscount: 0.1 21 | ), 22 | Product( 23 | id: 2, 24 | title: "T-shirt", 25 | price: 20, 26 | description: "This is a description of a cool T-shirt", 27 | category: "SomeArticle", 28 | imageURL: URL(string: "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg")! 29 | ), 30 | Product( 31 | id: 3, 32 | title: "T-shirt", 33 | price: 20, 34 | description: "This is a description of a cool T-shirt", 35 | category: "SomeArticle", 36 | imageURL: URL(string: "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg")! 37 | ), 38 | ] 39 | } 40 | 41 | static var uiTestSample: [Product] { 42 | [ 43 | Product( 44 | id: 1, 45 | title: "Fjallraven - Foldsack No. 1 Backpack, Fits 15 Laptops", 46 | price: 20, 47 | 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", 48 | category: "men's clothing", 49 | imageURL: URL(string: "https://fakestoreapi.com/img/81fPKd-2AYL._AC_SL1500_.jpg")!, 50 | percentageDiscount: 0.1 51 | ), 52 | Product( 53 | id: 2, 54 | title: "Mens Casual Premium Slim Fit T-Shirts", 55 | price: 20, 56 | 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.", 57 | category: "men's clothing", 58 | imageURL: URL(string: "https://fakestoreapi.com/img/71-3HjGNDUL._AC_SY879._SX._UX._SY._UY_.jpg")! 59 | ), 60 | Product( 61 | id: 3, 62 | title: "Mens Cotton Jacket", 63 | price: 20, 64 | 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.", 65 | category: "men's clothing", 66 | imageURL: URL(string: "https://fakestoreapi.com/img/71li-ujtlUL._AC_UX679_.jpg")! 67 | ), 68 | Product( 69 | id: 4, 70 | title: "Mens Casual Slim Fit", 71 | price: 20, 72 | description: "The color could be slightly different between on the screen and in practice. / Please note that body builds vary by person, therefore, detailed size information should be reviewed below on the product description.", 73 | category: "men's clothing", 74 | imageURL: URL(string: "https://fakestoreapi.com/img/71YXzeOuslL._AC_UY879_.jpg")! 75 | ), 76 | Product( 77 | id: 5, 78 | title: "John Hardy Women's Legends Naga Gold & Silver Dragon Station Chain Bracelet", 79 | price: 20, 80 | description: "From our Legends Collection, the Naga was inspired by the mythical water dragon that protects the ocean's pearl. Wear facing inward to be bestowed with love and abundance, or outward for protection.", 81 | category: "jewelery", 82 | imageURL: URL(string: "https://fakestoreapi.com/img/71pWzhdJNwL._AC_UL640_QL65_ML3_.jpg")! 83 | ), 84 | Product( 85 | id: 6, 86 | title: "Solid Gold Petite Micropave ", 87 | price: 20, 88 | description: "Satisfaction Guaranteed. Return or exchange any order within 30 days.Designed and sold by Hafeez Center in the United States. Satisfaction Guaranteed. Return or exchange any order within 30 days.", 89 | category: "jewelery", 90 | imageURL: URL(string: "https://fakestoreapi.com/img/61sbMiUnoGL._AC_UL640_QL65_ML3_.jpg")! 91 | ), 92 | ] 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /OnlineStoreMV/Network/APIClient.swift: -------------------------------------------------------------------------------- 1 | // 2 | // APIClient.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 05/03/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct APIClient { 11 | var fetchProducts: () async throws -> [Product] 12 | var sendOrder: ([CartItem]) async throws -> String 13 | var fetchUserProfile: () async throws -> UserProfile 14 | 15 | struct Failure: Error, Equatable {} 16 | } 17 | 18 | // This is the "live" fact dependency that reaches into the outside world to fetch the data from network. 19 | // Typically this live implementation of the dependency would live in its own module so that the 20 | // main feature doesn't need to compile it. 21 | extension APIClient { 22 | static let live = Self( 23 | fetchProducts: { 24 | let (data, _) = try await URLSession.shared 25 | .data(from: URL(string: "https://fakestoreapi.com/products")!) 26 | let products = try JSONDecoder().decode([Product].self, from: data) 27 | return products 28 | }, 29 | sendOrder: { cartItems in 30 | let payload = try JSONEncoder().encode(cartItems) 31 | var urlRequest = URLRequest(url: URL(string: "https://fakestoreapi.com/carts")!) 32 | urlRequest.addValue("application/json", forHTTPHeaderField: "Content-Type") 33 | urlRequest.httpMethod = "POST" 34 | 35 | let (data, response) = try await URLSession.shared.upload(for: urlRequest, from: payload) 36 | 37 | guard let httpResponse = (response as? HTTPURLResponse) else { 38 | throw Failure() 39 | } 40 | 41 | return "Status: \(httpResponse.statusCode)" 42 | }, 43 | fetchUserProfile: { 44 | let (data, _) = try await URLSession.shared 45 | .data(from: URL(string: "https://fakestoreapi.com/users/1")!) 46 | let profile = try JSONDecoder().decode(UserProfile.self, from: data) 47 | return profile 48 | } 49 | ) 50 | #if DEBUG 51 | static let testSuccess = Self( 52 | fetchProducts: { 53 | try await Task.sleep(nanoseconds: 3_000_000_000) 54 | return Product.sample 55 | }, 56 | sendOrder: { cartItems in 57 | "OK" 58 | }, 59 | fetchUserProfile: { 60 | try await Task.sleep(nanoseconds: 1000) 61 | return UserProfile(id: 100, email: "test@test.com", firstName: "Test", lastName: "Lopez") 62 | } 63 | ) 64 | static let testEmpty = Self( 65 | fetchProducts: { 66 | try await Task.sleep(nanoseconds: 1000) 67 | return [] 68 | }, 69 | sendOrder: { cartItems in 70 | "OK" 71 | }, 72 | fetchUserProfile: { 73 | try await Task.sleep(nanoseconds: 1000) 74 | return UserProfile(id: 100, email: "test@test.com", firstName: "Test", lastName: "Lopez") 75 | } 76 | ) 77 | static let testError = Self( 78 | fetchProducts: { 79 | try await Task.sleep(nanoseconds: 1000) 80 | throw Failure() 81 | }, 82 | sendOrder: { cartItems in 83 | "OK" 84 | }, 85 | fetchUserProfile: { 86 | try await Task.sleep(nanoseconds: 1000) 87 | return UserProfile(id: 100, email: "test@test.com", firstName: "Test", lastName: "Lopez") 88 | } 89 | ) 90 | 91 | static let uiTestSuccess = Self( 92 | fetchProducts: { 93 | try await Task.sleep(nanoseconds: 3_000_000_000) 94 | return Product.uiTestSample 95 | }, 96 | sendOrder: { cartItems in 97 | try await Task.sleep(nanoseconds: 3_000_000_000) 98 | return "OK" 99 | }, 100 | fetchUserProfile: { 101 | try await Task.sleep(nanoseconds: 3_000_000_000) 102 | return UserProfile(id: 100, email: "test@test.com", firstName: "Test", lastName: "Lopez") 103 | } 104 | ) 105 | 106 | static let uiTestFailure = Self( 107 | fetchProducts: { 108 | try await Task.sleep(nanoseconds: 3_000_000_000) 109 | throw Failure() 110 | }, 111 | sendOrder: { cartItems in 112 | try await Task.sleep(nanoseconds: 3_000_000_000) 113 | throw Failure() 114 | }, 115 | fetchUserProfile: { 116 | try await Task.sleep(nanoseconds: 3_000_000_000) 117 | throw Failure() 118 | } 119 | ) 120 | #endif 121 | } 122 | 123 | // This code is only for demo purposes 124 | extension APIClient { 125 | func fetchProducts(completion: @escaping (Result<[Product], Error>) -> Void) { 126 | Task { 127 | do { 128 | let products = try await fetchProducts() 129 | completion(.success(products)) 130 | } catch { 131 | completion(.failure(error)) 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /OnlineStoreMV/Cart/CartListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CartListView.swift 3 | // OnlineStoreMV 4 | // 5 | // Created by Pedro Rojas on 06/03/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CartListView: View { 11 | @Environment(CartStore.self) private var cartStore 12 | @Environment(\.dismiss) private var dismiss 13 | 14 | @State private var showConfirmationAlert = false 15 | @State private var showSuccessAlert = false 16 | @State private var showErrorAlert = false 17 | @State private var isLoading = false 18 | 19 | var isPayButtonDisable: Bool { 20 | cartStore.totalAmount() == 0 21 | } 22 | 23 | var body: some View { 24 | ZStack { 25 | NavigationStack { 26 | Group { 27 | if cartStore.cartItems.isEmpty { 28 | Text("Oops, your cart is empty! \n") 29 | .font(.custom("AmericanTypewriter", size: 25)) 30 | } else { 31 | List(cartStore.cartItems) { item in 32 | CartCell(cartItem: item) 33 | } 34 | .accessibilityIdentifier("cartList") 35 | .safeAreaInset(edge: .bottom) { 36 | Button { 37 | showConfirmationAlert = true 38 | } label: { 39 | HStack(alignment: .center) { 40 | Spacer() 41 | Text("Pay \(cartStore.totalPriceString)") 42 | .font(.custom("AmericanTypewriter", size: 30)) 43 | .foregroundColor(.white) 44 | 45 | Spacer() 46 | } 47 | 48 | } 49 | .accessibilityIdentifier("payButton") 50 | .frame(maxWidth: .infinity, minHeight: 60) 51 | .background( 52 | isPayButtonDisable 53 | ? .gray 54 | : .blue 55 | ) 56 | .cornerRadius(10) 57 | .padding() 58 | .disabled(isPayButtonDisable) 59 | } 60 | } 61 | } 62 | .navigationTitle("Cart") 63 | .toolbar { 64 | ToolbarItem(placement: .navigationBarLeading) { 65 | Button { 66 | dismiss() 67 | } label: { 68 | Text("Close") 69 | } 70 | } 71 | } 72 | .alert( 73 | "Confirm your purchase", 74 | isPresented: $showConfirmationAlert, 75 | actions: { 76 | Button(role: .cancel) { 77 | showConfirmationAlert = false 78 | } label: { 79 | Text("Cancel") 80 | } 81 | Button("Yes") { 82 | Task { 83 | await cartStore.sendOrder() 84 | showSuccessAlert = cartStore.sendOrderStatus == .success 85 | showErrorAlert = cartStore.sendOrderStatus == .error 86 | } 87 | } 88 | }, 89 | message: { Text("Do you want to proceed with your purchase of \(cartStore.totalPriceString)?") } 90 | ) 91 | .alert( 92 | "Thank you!", 93 | isPresented: $showSuccessAlert, 94 | actions: { 95 | Button("Done") { 96 | showSuccessAlert = false 97 | dismiss() 98 | cartStore.removeAllItems() 99 | } 100 | }, 101 | message: { Text("Your order is in process.") } 102 | ) 103 | .alert( 104 | "Oops!", 105 | isPresented: $showErrorAlert, 106 | actions: { 107 | Button("Done") { 108 | showErrorAlert = false 109 | } 110 | }, 111 | message: { Text("Unable to send order, try again later.") } 112 | ) 113 | } 114 | if cartStore.isSendingOrder { 115 | Color.black.opacity(0.2) 116 | .ignoresSafeArea() 117 | ProgressView() 118 | .accessibilityIdentifier("progressViewPayment") 119 | } 120 | } 121 | } 122 | } 123 | 124 | #if DEBUG 125 | #Preview { 126 | CartListView() 127 | .environment( 128 | CartStore( 129 | cartItems: CartItem.sample, 130 | apiClient: .testSuccess, 131 | logger: .inMemory 132 | ) 133 | ) 134 | } 135 | #endif 136 | -------------------------------------------------------------------------------- /OnlineStoreMV/Test/UI/OnlineStoreMV_UITests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnlineStoreMV_UITests.swift 3 | // OnlineStoreMVUITests 4 | // 5 | // Created by Pedro Rojas on 01/10/24. 6 | // 7 | 8 | import XCTest 9 | 10 | final class OnlineStoreMV_UITests: XCTestCase { 11 | 12 | var app: XCUIApplication! 13 | private var _TestMode = "Test_Mode" 14 | private var _Success = "SUCCESS" 15 | private var _Failure = "FAILURE" 16 | 17 | 18 | override func setUp() { 19 | super.setUp() 20 | continueAfterFailure = false 21 | app = XCUIApplication() 22 | app.launchEnvironment["UI_Testing_Enabled"] = "YES" 23 | } 24 | 25 | @MainActor 26 | func testLoadProductsScreenComponents() { 27 | app.launchEnvironment[_TestMode] = _Success 28 | app.launch() 29 | 30 | let navTitle = app.staticTexts["Products"] 31 | XCTAssertTrue(navTitle.exists, "Products title not found") 32 | 33 | let progressView = app.activityIndicators["progressViewProductList"] 34 | XCTAssertTrue(progressView.waitForExistence(timeout: 5), "The ProgressView should appear while loading.") 35 | 36 | let goToCartButton = app.buttons["goToCartButton"] 37 | XCTAssertTrue(goToCartButton.exists, "The goToCartButton is not visible.") 38 | 39 | let productList = app.collectionViews["productList"] 40 | XCTAssertTrue(productList.waitForExistence(timeout: 3), "The product list should appear after loading finishes.") 41 | } 42 | 43 | @MainActor 44 | func testSendingTwoProductsToCart() { 45 | app.launchEnvironment[_TestMode] = _Success 46 | app.launch() 47 | 48 | let productList = app.collectionViews["productList"] 49 | XCTAssertTrue(productList.waitForExistence(timeout: 3), "The product list should appear after loading finishes.") 50 | 51 | let cell1 = productList.children(matching: .cell).element(boundBy: 0) 52 | cell1.buttons["Add to Cart"].tap() 53 | 54 | let cell2 = productList.children(matching: .cell).element(boundBy: 1) 55 | cell2.swipeUp() 56 | cell2.swipeUp() 57 | cell2.buttons["Add to Cart"].tap() 58 | 59 | 60 | XCTAssertTrue(cell1.staticTexts["productQuantity"].label == "1", "Product quantity is not 1") 61 | XCTAssertTrue(cell2.staticTexts["productQuantity"].label == "1", "Product quantity is not 1") 62 | 63 | 64 | let goToCartButton = app.buttons["goToCartButton"] 65 | goToCartButton.tap() 66 | 67 | 68 | let cartList = app.collectionViews["cartList"] 69 | XCTAssertTrue(cartList.waitForExistence(timeout: 3), "The cart list is not appearing.") 70 | XCTAssertTrue(cartList.cells.count == 2, "The cart list should contain two products.") 71 | } 72 | 73 | @MainActor 74 | func testPayOrder() { 75 | app.launchEnvironment[_TestMode] = _Success 76 | app.launch() 77 | 78 | let productList = app.collectionViews["productList"] 79 | XCTAssertTrue(productList.waitForExistence(timeout: 3), "The product list should appear after loading finishes.") 80 | 81 | //Adding 3 items to the cart (total 3) 82 | let cell1 = productList.children(matching: .cell).element(boundBy: 0) 83 | cell1.buttons["Add to Cart"].tap() 84 | cell1/*@START_MENU_TOKEN@*/.buttons["+"]/*[[".cells.buttons[\"+\"]",".buttons[\"+\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() 85 | cell1/*@START_MENU_TOKEN@*/.buttons["+"]/*[[".cells.buttons[\"+\"]",".buttons[\"+\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() 86 | 87 | //Adding 1 item to the cart (total 4) 88 | let cell2 = productList.children(matching: .cell).element(boundBy: 1) 89 | cell2.swipeUp() 90 | cell2.swipeUp() 91 | cell2.buttons["Add to Cart"].tap() 92 | cell2.swipeUp() 93 | 94 | //Adding 3 items to the cart (total 7) 95 | let cell3 = productList.children(matching: .cell).element(boundBy: 0) 96 | cell3.buttons["Add to Cart"].tap() 97 | cell3/*@START_MENU_TOKEN@*/.buttons["+"]/*[[".cells.buttons[\"+\"]",".buttons[\"+\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() 98 | cell3/*@START_MENU_TOKEN@*/.buttons["+"]/*[[".cells.buttons[\"+\"]",".buttons[\"+\"]"],[[[-1,1],[-1,0]]],[0]]@END_MENU_TOKEN@*/.tap() 99 | 100 | let goToCartButton = app.buttons["goToCartButton"] 101 | goToCartButton.tap() 102 | 103 | 104 | let cartList = app.collectionViews["cartList"] 105 | XCTAssertTrue(cartList.waitForExistence(timeout: 3), "The cart list is not appearing.") 106 | 107 | let payButton = app.buttons["payButton"] 108 | payButton.tap() 109 | 110 | let confirmationAlert = app.alerts["Confirm your purchase"] 111 | XCTAssertTrue(confirmationAlert.waitForExistence(timeout: 3), "The confirmation alert should appear.") 112 | 113 | let alertDescription = confirmationAlert.staticTexts.element(boundBy: 1).label 114 | XCTAssertTrue(alertDescription.contains("$130"), "The total amount should be $130.00.") 115 | confirmationAlert.buttons["Yes"].tap() 116 | 117 | let progressView = app.activityIndicators["progressViewPayment"] 118 | XCTAssertTrue(progressView.waitForExistence(timeout: 3), "The payment progress indicator should appear.") 119 | 120 | app.alerts["Thank you!"] 121 | .buttons["Done"].tap() 122 | 123 | XCTAssertTrue(productList.waitForExistence(timeout: 3), "The product list should appear after paying.") 124 | } 125 | 126 | @MainActor 127 | func testApiClientError() { 128 | app.launchEnvironment[_TestMode] = _Failure 129 | app.launch() 130 | 131 | let progressView = app.activityIndicators["progressViewProductList"] 132 | XCTAssertTrue(progressView.waitForExistence(timeout: 5), "The ProgressView should appear while loading.") 133 | 134 | let errorImage = app.images["errorCloud"] 135 | XCTAssertTrue(errorImage.waitForExistence(timeout: 5), "The error image should appear after loading.") 136 | } 137 | 138 | // func testLaunchPerformance() throws { 139 | // if #available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 7.0, *) { 140 | // // This measures how long it takes to launch your application. 141 | // measure(metrics: [XCTApplicationLaunchMetric()]) { 142 | // app.launch() 143 | // } 144 | // } 145 | // } 146 | } 147 | -------------------------------------------------------------------------------- /OnlineStoreMV/Test/ProductStoreTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ProductStoreTest.swift 3 | // OnlineStoreMVTests 4 | // 5 | // Created by Pedro Rojas on 09/08/24. 6 | // 7 | 8 | import XCTest 9 | import Testing 10 | 11 | @testable import OnlineStoreMV 12 | 13 | struct ProductStoreTest { 14 | 15 | @Test 16 | func fetchThreeProductsFromAPI() async { 17 | let productStore = ProductStore( 18 | apiClient: .testSuccess, 19 | databaseClient: .inMemory, 20 | discountCalculator: .init(discountProvider: .empty), 21 | logger: .inMemory 22 | ) 23 | await productStore.fetchProducts() 24 | 25 | guard case .loaded(let products) = productStore.loadingState else { 26 | Issue.record("Expected .loaded state but got \(productStore.loadingState)") 27 | return 28 | } 29 | 30 | #expect(products.count == 3) 31 | } 32 | 33 | @Suite 34 | struct IntegrationTests { 35 | func createTempFileURLForTest( 36 | named testName: String = #function 37 | ) -> URL { 38 | let tempDirectory = FileManager.default.temporaryDirectory 39 | let fileURL = tempDirectory.appendingPathComponent(testName) 40 | 41 | //Ensuring the previous temp file is deleted. 42 | try? FileManager.default.removeItem(atPath: fileURL.path) 43 | 44 | return fileURL 45 | } 46 | 47 | @Test 48 | func fetchThreeProductsFromCache() async throws { 49 | let logger = Logger.fileLogging(fileURL: createTempFileURLForTest()) 50 | let database = DatabaseClient.inMemory 51 | let productStore = ProductStore( 52 | apiClient: .testSuccess, 53 | databaseClient: database, 54 | discountCalculator: .init(discountProvider: .live), 55 | logger: logger 56 | ) 57 | 58 | logger.clear() 59 | try #require(logger.loggedMessages.isEmpty, "Logger is not empty") 60 | 61 | database.clear() 62 | try #require(database.fetchCachedProducts().isEmpty, "Cannot test fetching products when database is not empty") 63 | try #require(productStore.products.isEmpty, "Cannot test fetching products when products are not empty") 64 | 65 | // Fetching products from API 66 | await productStore.fetchProducts() 67 | 68 | guard case .loaded(let products) = productStore.loadingState else { 69 | Issue.record("Expected .loaded state but got \(productStore.loadingState)") 70 | return 71 | } 72 | 73 | #expect(products.count == 3) 74 | #expect(products[0].hasDiscount) 75 | #expect(products[1].hasDiscount) 76 | #expect(!products[2].hasDiscount) // Regular price 77 | 78 | #expect(database.fetchCachedProducts().count == 3) 79 | 80 | let lastLoggedMessageFromAPI = try #require(logger.loggedMessages.last) 81 | #expect(lastLoggedMessageFromAPI.contains("Fetched products from API and applied discounts.")) 82 | 83 | // Fetching products from Cache 84 | await productStore.fetchProducts() 85 | 86 | guard case .loaded(let products) = productStore.loadingState else { 87 | Issue.record("Expected .loaded state but got \(productStore.loadingState)") 88 | return 89 | } 90 | 91 | #expect(products.count == 3) 92 | #expect(products[0].hasDiscount) 93 | #expect(products[1].hasDiscount) 94 | #expect(!products[2].hasDiscount) 95 | 96 | #expect(database.fetchCachedProducts().count == 3) 97 | 98 | let lastLoggedMessageFromCache = try #require(logger.loggedMessages.last) 99 | #expect(lastLoggedMessageFromCache.contains("Fetched products from cache.")) 100 | } 101 | } 102 | 103 | @Test 104 | func testFetchThreeProductsFromAPIDeprecated() async throws { 105 | let productStore = ProductStore( 106 | apiClient: .testSuccess, 107 | databaseClient: .inMemory, 108 | discountCalculator: .init(discountProvider: .live), 109 | logger: .inMemory 110 | ) 111 | 112 | do { 113 | let products = try await withCheckedThrowingContinuation { continuation in 114 | productStore.fetchProducts { result in 115 | switch result { 116 | case .success(let products): 117 | continuation.resume(returning: products) 118 | case .failure(let error): 119 | continuation.resume(throwing: error) 120 | } 121 | } 122 | } 123 | 124 | #expect(products.count == 3) 125 | } catch { 126 | Issue.record("Expected .loaded state but got \(productStore.loadingState)") 127 | } 128 | } 129 | } 130 | 131 | final class ProductStoreTest_deprecated: XCTest { 132 | func testFetchThreeProductsFromAPI() async { 133 | let productStore = ProductStore( 134 | apiClient: .testSuccess, 135 | databaseClient: .inMemory, 136 | discountCalculator: .init(discountProvider: .live), 137 | logger: .inMemory 138 | ) 139 | await productStore.fetchProducts() 140 | 141 | guard case .loaded(let products) = productStore.loadingState else { 142 | XCTFail("Expected .loaded state but got \(productStore.loadingState)") 143 | return 144 | } 145 | 146 | XCTAssertEqual(products.count, 3) 147 | } 148 | 149 | func testFetchThreeProductsFromAPIDeprecated() { 150 | let productStore = ProductStore( 151 | apiClient: .testError, 152 | databaseClient: .inMemory, 153 | discountCalculator: .init(discountProvider: .live), 154 | logger: .inMemory 155 | ) 156 | 157 | let expectation = XCTestExpectation(description: "Fetch products") 158 | 159 | productStore.fetchProducts { result in 160 | switch result { 161 | case .success(let products): 162 | XCTAssertEqual(products.count, 3) 163 | case .failure(let error): 164 | XCTFail("Expected .loaded state but got \(productStore.loadingState). Error: \(error.localizedDescription)") 165 | } 166 | expectation.fulfill() 167 | } 168 | 169 | _ = XCTWaiter.wait(for: [expectation], timeout: 1) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /OnlineStoreMV/Test/CartStoreTest.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CartStoreTest.swift 3 | // OnlineStoreMVTests 4 | // 5 | // Created by Pedro Rojas on 07/03/24. 6 | // 7 | 8 | import XCTest 9 | import Testing 10 | 11 | @testable import OnlineStoreMV 12 | 13 | extension Tag { 14 | @Tag static let adding: Self 15 | @Tag static let substracting: Self 16 | @Tag static let removing: Self 17 | @Tag static let quantity: Self 18 | @Tag static let product: Self 19 | @Tag static let price: Self 20 | } 21 | 22 | let products = [ 23 | Product( 24 | id: 1, 25 | title: "test1", 26 | price: 123.12, 27 | description: "", 28 | category: "", 29 | imageURL: URL(string: "www.apple.com")! 30 | ), 31 | Product( 32 | id: 2, 33 | title: "test2", 34 | price: 77.56, 35 | description: "", 36 | category: "", 37 | imageURL: URL(string: "www.apple.com")! 38 | ), 39 | Product( 40 | id: 3, 41 | title: "test2", 42 | price: 91.0, 43 | description: "", 44 | category: "", 45 | imageURL: URL(string: "www.apple.com")! 46 | ) 47 | ] 48 | let cartItems = [ 49 | CartItem( 50 | product: products[0], 51 | quantity: 3 52 | ), 53 | CartItem( 54 | product: products[1], 55 | quantity: 1 56 | ), 57 | CartItem( 58 | product: products[2], 59 | quantity: 2 60 | ), 61 | ] 62 | 63 | struct CartStoreTest { 64 | 65 | @Test("Get total amount to pay as string", .tags(.price)) 66 | func totalAmountString() throws { 67 | let cartStore = CartStore( 68 | cartItems: cartItems, 69 | apiClient: .testSuccess, 70 | logger: .inMemory 71 | ) 72 | 73 | #expect(cartStore.totalPriceString == "$628.92") 74 | } 75 | 76 | @Suite("Substracting Quantity on Cart Items",.tags(.substracting, .quantity)) 77 | struct SubstractingTest { 78 | 79 | @Test 80 | func quantityFromItemInCart() { 81 | let cartStore = CartStore( 82 | cartItems: cartItems, 83 | apiClient: .testSuccess, 84 | logger: .inMemory 85 | ) 86 | 87 | cartStore.removeFromCart(product: products[0]) 88 | cartStore.removeFromCart(product: products[2]) 89 | let quantity = cartStore.cartItems.reduce(0) { 90 | $0 + $1.quantity 91 | } 92 | 93 | #expect(quantity == 4) 94 | } 95 | 96 | @Test 97 | func quantityFromItemInCartUntilMakeItZero() { 98 | let cartStore = CartStore( 99 | cartItems: cartItems, 100 | apiClient: .testSuccess, 101 | logger: .inMemory 102 | ) 103 | 104 | cartStore.removeFromCart(product: products[0]) 105 | cartStore.removeFromCart(product: products[1]) 106 | cartStore.removeFromCart(product: products[2]) 107 | cartStore.removeFromCart(product: products[2]) 108 | 109 | let quantity = cartStore.cartItems.reduce(0) { 110 | $0 + $1.quantity 111 | } 112 | 113 | #expect(quantity == 2) 114 | } 115 | } 116 | 117 | @Suite("Removing Items from Cart", .tags(.removing)) 118 | struct RemovingTest { 119 | 120 | @Test(.tags(.product, .quantity), arguments: [ 121 | (products[0],3), 122 | (products[1],5), 123 | (products[2],4), 124 | ]) 125 | func oneProductFromCart(product: Product, expectedQuantity: Int) { 126 | let cartStore = CartStore( 127 | cartItems: cartItems, 128 | apiClient: .testSuccess, 129 | logger: .inMemory 130 | ) 131 | 132 | cartStore.removeAllFromCart(product: product) 133 | let quantity = cartStore.cartItems.reduce(0) { 134 | $0 + $1.quantity 135 | } 136 | 137 | #expect(cartStore.cartItems.count == 2) 138 | #expect(quantity == expectedQuantity) 139 | } 140 | 141 | @Test(.tags(.product)) 142 | func allItemsFromCart() { 143 | let cartStore = CartStore( 144 | cartItems: cartItems, 145 | apiClient: .testSuccess, 146 | logger: .inMemory 147 | ) 148 | 149 | cartStore.removeAllItems() 150 | 151 | #expect(cartStore.cartItems.isEmpty) 152 | } 153 | } 154 | 155 | @Suite( 156 | "Test quantity after some operations", 157 | .tags(.quantity) 158 | ) 159 | struct QuantityTest { 160 | @Test(.tags(.product, .quantity)) 161 | func productInCart() { 162 | let cartItems = [ 163 | CartItem( 164 | product: products[0], 165 | quantity: 4 166 | ) 167 | ] 168 | let cartStore = CartStore( 169 | cartItems: cartItems, 170 | apiClient: .testSuccess, 171 | logger: .inMemory 172 | ) 173 | 174 | let quantity = cartStore.quantity(for: products[0]) 175 | #expect(quantity == 4) 176 | } 177 | 178 | @Test(.tags(.product, .quantity)) 179 | func nonExistingProductInCart() { 180 | let unknownProduct = Product( 181 | id: 1000, 182 | title: "test1", 183 | price: 123.12, 184 | description: "", 185 | category: "", 186 | imageURL: URL(string: "www.apple.com")! 187 | ) 188 | let cartItems = [ 189 | CartItem( 190 | product: products[0], 191 | quantity: 4 192 | ) 193 | ] 194 | let cartStore = CartStore( 195 | cartItems: cartItems, 196 | apiClient: .testSuccess, 197 | logger: .inMemory 198 | ) 199 | 200 | let quantity = cartStore.quantity(for: unknownProduct) 201 | 202 | #expect(quantity == 0) 203 | } 204 | } 205 | 206 | @Suite("Adding Items To Cart", .tags(.adding)) 207 | struct AddingToCartTest { 208 | @Test(.tags(.quantity)) 209 | func addQuantityFromExistingItem() { 210 | let cartStore = CartStore( 211 | cartItems: cartItems, 212 | apiClient: .testSuccess, 213 | logger: .inMemory 214 | ) 215 | 216 | cartStore.addToCart(product: products[0]) 217 | cartStore.addToCart(product: products[1]) 218 | cartStore.addToCart(product: products[2]) 219 | let quantity = cartStore.cartItems.reduce(0) { 220 | $0 + $1.quantity 221 | } 222 | 223 | #expect(quantity == 9) 224 | } 225 | 226 | @Test 227 | func addQuantityFromNewItem() { 228 | let cartItems = [ 229 | CartItem( 230 | product: products[0], 231 | quantity: 3 232 | ) 233 | ] 234 | let cartStore = CartStore( 235 | cartItems: cartItems, 236 | apiClient: .testSuccess, 237 | logger: .inMemory 238 | ) 239 | 240 | cartStore.addToCart(product: products[0]) 241 | cartStore.addToCart(product: products[1]) 242 | 243 | #expect(cartStore.cartItems.count == 2) 244 | } 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /OnlineStoreMV.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 56; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | B50DF7162C93258100DF0C4D /* DatabaseClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50DF7152C93258100DF0C4D /* DatabaseClient.swift */; }; 11 | B50DF7192C98634B00DF0C4D /* DiscountCalculator.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50DF7182C98634B00DF0C4D /* DiscountCalculator.swift */; }; 12 | B50DF71B2C98658100DF0C4D /* DiscountProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50DF71A2C98658100DF0C4D /* DiscountProvider.swift */; }; 13 | B50DF71E2C98E9BE00DF0C4D /* LoggingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50DF71D2C98E9BE00DF0C4D /* LoggingStrategy.swift */; }; 14 | B50DF7202C98EAD000DF0C4D /* InMemoryStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50DF71F2C98EACB00DF0C4D /* InMemoryStrategy.swift */; }; 15 | B50DF7222C98ED7F00DF0C4D /* FileLoggingStrategy.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50DF7212C98ED7F00DF0C4D /* FileLoggingStrategy.swift */; }; 16 | B50DF7242C98FF8000DF0C4D /* Logger.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50DF7232C98FF8000DF0C4D /* Logger.swift */; }; 17 | B50DFF3C2BE6A25800039CCE /* Product+Sample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50DFF3B2BE6A25800039CCE /* Product+Sample.swift */; }; 18 | B50DFF3E2BE6A3CB00039CCE /* CartItem+Sample.swift in Sources */ = {isa = PBXBuildFile; fileRef = B50DFF3D2BE6A3CB00039CCE /* CartItem+Sample.swift */; }; 19 | B51F118D2BE29AD20005C4DC /* ProductErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B51F118C2BE29AD20005C4DC /* ProductErrorView.swift */; }; 20 | B53800D52C1C88DE001C5AB8 /* Testing in Frameworks */ = {isa = PBXBuildFile; productRef = B53800D42C1C88DE001C5AB8 /* Testing */; }; 21 | B559E6762B96EDD3002218EB /* Product.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6752B96EDD3002218EB /* Product.swift */; }; 22 | B559E6782B96EDFC002218EB /* ProductCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6772B96EDFC002218EB /* ProductCell.swift */; }; 23 | B559E67A2B977A45002218EB /* AddToCartButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6792B977A45002218EB /* AddToCartButton.swift */; }; 24 | B559E67D2B977B43002218EB /* PlusMinusButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E67C2B977B43002218EB /* PlusMinusButton.swift */; }; 25 | B559E6842B97856E002218EB /* CartStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6832B97856E002218EB /* CartStore.swift */; }; 26 | B559E6862B978624002218EB /* CartItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6852B978624002218EB /* CartItem.swift */; }; 27 | B559E6882B97A6EE002218EB /* ProductStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6872B97A6EE002218EB /* ProductStore.swift */; }; 28 | B559E68B2B982E6E002218EB /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E68A2B982E6E002218EB /* APIClient.swift */; }; 29 | B559E6962B984D84002218EB /* ProductList.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6952B984D84002218EB /* ProductList.swift */; }; 30 | B559E69B2B9994AD002218EB /* CartListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E69A2B9994AD002218EB /* CartListView.swift */; }; 31 | B559E69D2B9995B8002218EB /* CartCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E69C2B9995B8002218EB /* CartCell.swift */; }; 32 | B559E69F2B9A66D7002218EB /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E69E2B9A66D7002218EB /* UserProfile.swift */; }; 33 | B559E6A22B9A671B002218EB /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6A12B9A671B002218EB /* ProfileView.swift */; }; 34 | B559E6A42B9A67B9002218EB /* AccountStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6A32B9A67B9002218EB /* AccountStore.swift */; }; 35 | B559E6A72B9A7B5F002218EB /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6A62B9A7B5F002218EB /* RootView.swift */; }; 36 | B559E6B92B9AF8C3002218EB /* CartStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B559E6B82B9AF8C3002218EB /* CartStoreTest.swift */; }; 37 | B5660BD12CAC514F00380A27 /* OnlineStoreMV_UITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5660BCF2CAC514F00380A27 /* OnlineStoreMV_UITests.swift */; }; 38 | B57B56B62C66E1FF00D90D0F /* ProductStoreTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = B57B56B52C66E1FF00D90D0F /* ProductStoreTest.swift */; }; 39 | B58F25E82B96E58E000CAD57 /* OnlineStoreMVApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B58F25E72B96E58E000CAD57 /* OnlineStoreMVApp.swift */; }; 40 | B58F25EC2B96E58F000CAD57 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B58F25EB2B96E58F000CAD57 /* Assets.xcassets */; }; 41 | B58F25F02B96E58F000CAD57 /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B58F25EF2B96E58F000CAD57 /* Preview Assets.xcassets */; }; 42 | /* End PBXBuildFile section */ 43 | 44 | /* Begin PBXContainerItemProxy section */ 45 | B559E6B12B9AF88D002218EB /* PBXContainerItemProxy */ = { 46 | isa = PBXContainerItemProxy; 47 | containerPortal = B58F25DC2B96E58E000CAD57 /* Project object */; 48 | proxyType = 1; 49 | remoteGlobalIDString = B58F25E32B96E58E000CAD57; 50 | remoteInfo = OnlineStoreMV; 51 | }; 52 | B5660BC72CAC50C200380A27 /* PBXContainerItemProxy */ = { 53 | isa = PBXContainerItemProxy; 54 | containerPortal = B58F25DC2B96E58E000CAD57 /* Project object */; 55 | proxyType = 1; 56 | remoteGlobalIDString = B58F25E32B96E58E000CAD57; 57 | remoteInfo = OnlineStoreMV; 58 | }; 59 | /* End PBXContainerItemProxy section */ 60 | 61 | /* Begin PBXFileReference section */ 62 | B50DF7152C93258100DF0C4D /* DatabaseClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseClient.swift; sourceTree = ""; }; 63 | B50DF7182C98634B00DF0C4D /* DiscountCalculator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscountCalculator.swift; sourceTree = ""; }; 64 | B50DF71A2C98658100DF0C4D /* DiscountProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscountProvider.swift; sourceTree = ""; }; 65 | B50DF71D2C98E9BE00DF0C4D /* LoggingStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingStrategy.swift; sourceTree = ""; }; 66 | B50DF71F2C98EACB00DF0C4D /* InMemoryStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InMemoryStrategy.swift; sourceTree = ""; }; 67 | B50DF7212C98ED7F00DF0C4D /* FileLoggingStrategy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLoggingStrategy.swift; sourceTree = ""; }; 68 | B50DF7232C98FF8000DF0C4D /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = ""; }; 69 | B50DFF3B2BE6A25800039CCE /* Product+Sample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Product+Sample.swift"; sourceTree = ""; }; 70 | B50DFF3D2BE6A3CB00039CCE /* CartItem+Sample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CartItem+Sample.swift"; sourceTree = ""; }; 71 | B51F118C2BE29AD20005C4DC /* ProductErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductErrorView.swift; sourceTree = ""; }; 72 | B559E6752B96EDD3002218EB /* Product.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Product.swift; sourceTree = ""; }; 73 | B559E6772B96EDFC002218EB /* ProductCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductCell.swift; sourceTree = ""; }; 74 | B559E6792B977A45002218EB /* AddToCartButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddToCartButton.swift; sourceTree = ""; }; 75 | B559E67C2B977B43002218EB /* PlusMinusButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlusMinusButton.swift; sourceTree = ""; }; 76 | B559E6832B97856E002218EB /* CartStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartStore.swift; sourceTree = ""; }; 77 | B559E6852B978624002218EB /* CartItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartItem.swift; sourceTree = ""; }; 78 | B559E6872B97A6EE002218EB /* ProductStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductStore.swift; sourceTree = ""; }; 79 | B559E68A2B982E6E002218EB /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = ""; }; 80 | B559E6952B984D84002218EB /* ProductList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductList.swift; sourceTree = ""; }; 81 | B559E69A2B9994AD002218EB /* CartListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartListView.swift; sourceTree = ""; }; 82 | B559E69C2B9995B8002218EB /* CartCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartCell.swift; sourceTree = ""; }; 83 | B559E69E2B9A66D7002218EB /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 84 | B559E6A12B9A671B002218EB /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 85 | B559E6A32B9A67B9002218EB /* AccountStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountStore.swift; sourceTree = ""; }; 86 | B559E6A62B9A7B5F002218EB /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; 87 | B559E6AD2B9AF88D002218EB /* OnlineStoreMVTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OnlineStoreMVTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 88 | B559E6B82B9AF8C3002218EB /* CartStoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartStoreTest.swift; sourceTree = ""; }; 89 | B5660BC12CAC50C200380A27 /* OnlineStoreMVUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OnlineStoreMVUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 90 | B5660BCF2CAC514F00380A27 /* OnlineStoreMV_UITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineStoreMV_UITests.swift; sourceTree = ""; }; 91 | B57B56B52C66E1FF00D90D0F /* ProductStoreTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductStoreTest.swift; sourceTree = ""; }; 92 | B58F25E42B96E58E000CAD57 /* OnlineStoreMV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OnlineStoreMV.app; sourceTree = BUILT_PRODUCTS_DIR; }; 93 | B58F25E72B96E58E000CAD57 /* OnlineStoreMVApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnlineStoreMVApp.swift; sourceTree = ""; }; 94 | B58F25EB2B96E58F000CAD57 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 95 | B58F25ED2B96E58F000CAD57 /* OnlineStoreMV.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OnlineStoreMV.entitlements; sourceTree = ""; }; 96 | B58F25EF2B96E58F000CAD57 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 97 | /* End PBXFileReference section */ 98 | 99 | /* Begin PBXFrameworksBuildPhase section */ 100 | B559E6AA2B9AF88D002218EB /* Frameworks */ = { 101 | isa = PBXFrameworksBuildPhase; 102 | buildActionMask = 2147483647; 103 | files = ( 104 | B53800D52C1C88DE001C5AB8 /* Testing in Frameworks */, 105 | ); 106 | runOnlyForDeploymentPostprocessing = 0; 107 | }; 108 | B5660BBE2CAC50C200380A27 /* Frameworks */ = { 109 | isa = PBXFrameworksBuildPhase; 110 | buildActionMask = 2147483647; 111 | files = ( 112 | ); 113 | runOnlyForDeploymentPostprocessing = 0; 114 | }; 115 | B58F25E12B96E58E000CAD57 /* Frameworks */ = { 116 | isa = PBXFrameworksBuildPhase; 117 | buildActionMask = 2147483647; 118 | files = ( 119 | ); 120 | runOnlyForDeploymentPostprocessing = 0; 121 | }; 122 | /* End PBXFrameworksBuildPhase section */ 123 | 124 | /* Begin PBXGroup section */ 125 | B50DF7142C93255600DF0C4D /* Database */ = { 126 | isa = PBXGroup; 127 | children = ( 128 | B50DF7152C93258100DF0C4D /* DatabaseClient.swift */, 129 | ); 130 | path = Database; 131 | sourceTree = ""; 132 | }; 133 | B50DF7172C98634100DF0C4D /* Discounts */ = { 134 | isa = PBXGroup; 135 | children = ( 136 | B50DF71A2C98658100DF0C4D /* DiscountProvider.swift */, 137 | B50DF7182C98634B00DF0C4D /* DiscountCalculator.swift */, 138 | ); 139 | path = Discounts; 140 | sourceTree = ""; 141 | }; 142 | B50DF71C2C98E9B500DF0C4D /* Logger */ = { 143 | isa = PBXGroup; 144 | children = ( 145 | B50DF7232C98FF8000DF0C4D /* Logger.swift */, 146 | B50DF7212C98ED7F00DF0C4D /* FileLoggingStrategy.swift */, 147 | B50DF71F2C98EACB00DF0C4D /* InMemoryStrategy.swift */, 148 | B50DF71D2C98E9BE00DF0C4D /* LoggingStrategy.swift */, 149 | ); 150 | path = Logger; 151 | sourceTree = ""; 152 | }; 153 | B559E6742B96EDC1002218EB /* Product */ = { 154 | isa = PBXGroup; 155 | children = ( 156 | B559E6772B96EDFC002218EB /* ProductCell.swift */, 157 | B559E6952B984D84002218EB /* ProductList.swift */, 158 | B51F118C2BE29AD20005C4DC /* ProductErrorView.swift */, 159 | ); 160 | path = Product; 161 | sourceTree = ""; 162 | }; 163 | B559E67B2B977B2F002218EB /* AddToCart */ = { 164 | isa = PBXGroup; 165 | children = ( 166 | B559E6792B977A45002218EB /* AddToCartButton.swift */, 167 | B559E67C2B977B43002218EB /* PlusMinusButton.swift */, 168 | ); 169 | path = AddToCart; 170 | sourceTree = ""; 171 | }; 172 | B559E6822B9783D5002218EB /* Model */ = { 173 | isa = PBXGroup; 174 | children = ( 175 | B559E6832B97856E002218EB /* CartStore.swift */, 176 | B559E6872B97A6EE002218EB /* ProductStore.swift */, 177 | B559E6A32B9A67B9002218EB /* AccountStore.swift */, 178 | B559E6852B978624002218EB /* CartItem.swift */, 179 | B559E6752B96EDD3002218EB /* Product.swift */, 180 | B559E69E2B9A66D7002218EB /* UserProfile.swift */, 181 | ); 182 | path = Model; 183 | sourceTree = ""; 184 | }; 185 | B559E6892B982E60002218EB /* Network */ = { 186 | isa = PBXGroup; 187 | children = ( 188 | B559E68A2B982E6E002218EB /* APIClient.swift */, 189 | ); 190 | path = Network; 191 | sourceTree = ""; 192 | }; 193 | B559E6992B99946E002218EB /* Cart */ = { 194 | isa = PBXGroup; 195 | children = ( 196 | B559E69A2B9994AD002218EB /* CartListView.swift */, 197 | B559E69C2B9995B8002218EB /* CartCell.swift */, 198 | ); 199 | path = Cart; 200 | sourceTree = ""; 201 | }; 202 | B559E6A02B9A670B002218EB /* Profile */ = { 203 | isa = PBXGroup; 204 | children = ( 205 | B559E6A12B9A671B002218EB /* ProfileView.swift */, 206 | ); 207 | path = Profile; 208 | sourceTree = ""; 209 | }; 210 | B559E6A52B9A7B4A002218EB /* Root */ = { 211 | isa = PBXGroup; 212 | children = ( 213 | B58F25E72B96E58E000CAD57 /* OnlineStoreMVApp.swift */, 214 | B559E6A62B9A7B5F002218EB /* RootView.swift */, 215 | ); 216 | path = Root; 217 | sourceTree = ""; 218 | }; 219 | B559E6A82B9AF872002218EB /* Test */ = { 220 | isa = PBXGroup; 221 | children = ( 222 | B5660BCC2CAC50D700380A27 /* UI */, 223 | B559E6B82B9AF8C3002218EB /* CartStoreTest.swift */, 224 | B57B56B52C66E1FF00D90D0F /* ProductStoreTest.swift */, 225 | ); 226 | path = Test; 227 | sourceTree = ""; 228 | }; 229 | B5660BCC2CAC50D700380A27 /* UI */ = { 230 | isa = PBXGroup; 231 | children = ( 232 | B5660BCF2CAC514F00380A27 /* OnlineStoreMV_UITests.swift */, 233 | ); 234 | path = UI; 235 | sourceTree = ""; 236 | }; 237 | B58F25DB2B96E58E000CAD57 = { 238 | isa = PBXGroup; 239 | children = ( 240 | B58F25E62B96E58E000CAD57 /* OnlineStoreMV */, 241 | B58F25E52B96E58E000CAD57 /* Products */, 242 | ); 243 | sourceTree = ""; 244 | }; 245 | B58F25E52B96E58E000CAD57 /* Products */ = { 246 | isa = PBXGroup; 247 | children = ( 248 | B58F25E42B96E58E000CAD57 /* OnlineStoreMV.app */, 249 | B559E6AD2B9AF88D002218EB /* OnlineStoreMVTests.xctest */, 250 | B5660BC12CAC50C200380A27 /* OnlineStoreMVUITests.xctest */, 251 | ); 252 | name = Products; 253 | sourceTree = ""; 254 | }; 255 | B58F25E62B96E58E000CAD57 /* OnlineStoreMV */ = { 256 | isa = PBXGroup; 257 | children = ( 258 | B50DF71C2C98E9B500DF0C4D /* Logger */, 259 | B50DF7172C98634100DF0C4D /* Discounts */, 260 | B50DF7142C93255600DF0C4D /* Database */, 261 | B559E6A82B9AF872002218EB /* Test */, 262 | B559E6A52B9A7B4A002218EB /* Root */, 263 | B559E6A02B9A670B002218EB /* Profile */, 264 | B559E6992B99946E002218EB /* Cart */, 265 | B559E6892B982E60002218EB /* Network */, 266 | B559E6822B9783D5002218EB /* Model */, 267 | B559E67B2B977B2F002218EB /* AddToCart */, 268 | B559E6742B96EDC1002218EB /* Product */, 269 | B58F25EB2B96E58F000CAD57 /* Assets.xcassets */, 270 | B58F25ED2B96E58F000CAD57 /* OnlineStoreMV.entitlements */, 271 | B58F25EE2B96E58F000CAD57 /* Preview Content */, 272 | ); 273 | path = OnlineStoreMV; 274 | sourceTree = ""; 275 | }; 276 | B58F25EE2B96E58F000CAD57 /* Preview Content */ = { 277 | isa = PBXGroup; 278 | children = ( 279 | B58F25EF2B96E58F000CAD57 /* Preview Assets.xcassets */, 280 | B50DFF3B2BE6A25800039CCE /* Product+Sample.swift */, 281 | B50DFF3D2BE6A3CB00039CCE /* CartItem+Sample.swift */, 282 | ); 283 | path = "Preview Content"; 284 | sourceTree = ""; 285 | }; 286 | /* End PBXGroup section */ 287 | 288 | /* Begin PBXNativeTarget section */ 289 | B559E6AC2B9AF88D002218EB /* OnlineStoreMVTests */ = { 290 | isa = PBXNativeTarget; 291 | buildConfigurationList = B559E6B32B9AF88D002218EB /* Build configuration list for PBXNativeTarget "OnlineStoreMVTests" */; 292 | buildPhases = ( 293 | B559E6A92B9AF88D002218EB /* Sources */, 294 | B559E6AA2B9AF88D002218EB /* Frameworks */, 295 | B559E6AB2B9AF88D002218EB /* Resources */, 296 | ); 297 | buildRules = ( 298 | ); 299 | dependencies = ( 300 | B559E6B22B9AF88D002218EB /* PBXTargetDependency */, 301 | ); 302 | name = OnlineStoreMVTests; 303 | packageProductDependencies = ( 304 | B53800D42C1C88DE001C5AB8 /* Testing */, 305 | ); 306 | productName = OnlineStoreMVTests; 307 | productReference = B559E6AD2B9AF88D002218EB /* OnlineStoreMVTests.xctest */; 308 | productType = "com.apple.product-type.bundle.unit-test"; 309 | }; 310 | B5660BC02CAC50C200380A27 /* OnlineStoreMVUITests */ = { 311 | isa = PBXNativeTarget; 312 | buildConfigurationList = B5660BCB2CAC50C200380A27 /* Build configuration list for PBXNativeTarget "OnlineStoreMVUITests" */; 313 | buildPhases = ( 314 | B5660BBD2CAC50C200380A27 /* Sources */, 315 | B5660BBE2CAC50C200380A27 /* Frameworks */, 316 | B5660BBF2CAC50C200380A27 /* Resources */, 317 | ); 318 | buildRules = ( 319 | ); 320 | dependencies = ( 321 | B5660BC82CAC50C200380A27 /* PBXTargetDependency */, 322 | ); 323 | name = OnlineStoreMVUITests; 324 | packageProductDependencies = ( 325 | ); 326 | productName = OnlineStoreMVUITests; 327 | productReference = B5660BC12CAC50C200380A27 /* OnlineStoreMVUITests.xctest */; 328 | productType = "com.apple.product-type.bundle.ui-testing"; 329 | }; 330 | B58F25E32B96E58E000CAD57 /* OnlineStoreMV */ = { 331 | isa = PBXNativeTarget; 332 | buildConfigurationList = B58F25F32B96E58F000CAD57 /* Build configuration list for PBXNativeTarget "OnlineStoreMV" */; 333 | buildPhases = ( 334 | B58F25E02B96E58E000CAD57 /* Sources */, 335 | B58F25E12B96E58E000CAD57 /* Frameworks */, 336 | B58F25E22B96E58E000CAD57 /* Resources */, 337 | ); 338 | buildRules = ( 339 | ); 340 | dependencies = ( 341 | ); 342 | name = OnlineStoreMV; 343 | productName = OnlineStoreMV; 344 | productReference = B58F25E42B96E58E000CAD57 /* OnlineStoreMV.app */; 345 | productType = "com.apple.product-type.application"; 346 | }; 347 | /* End PBXNativeTarget section */ 348 | 349 | /* Begin PBXProject section */ 350 | B58F25DC2B96E58E000CAD57 /* Project object */ = { 351 | isa = PBXProject; 352 | attributes = { 353 | BuildIndependentTargetsInParallel = 1; 354 | LastSwiftUpdateCheck = 1600; 355 | LastUpgradeCheck = 1510; 356 | TargetAttributes = { 357 | B559E6AC2B9AF88D002218EB = { 358 | CreatedOnToolsVersion = 15.1; 359 | TestTargetID = B58F25E32B96E58E000CAD57; 360 | }; 361 | B5660BC02CAC50C200380A27 = { 362 | CreatedOnToolsVersion = 16.0; 363 | LastSwiftMigration = 1600; 364 | TestTargetID = B58F25E32B96E58E000CAD57; 365 | }; 366 | B58F25E32B96E58E000CAD57 = { 367 | CreatedOnToolsVersion = 15.1; 368 | }; 369 | }; 370 | }; 371 | buildConfigurationList = B58F25DF2B96E58E000CAD57 /* Build configuration list for PBXProject "OnlineStoreMV" */; 372 | compatibilityVersion = "Xcode 14.0"; 373 | developmentRegion = en; 374 | hasScannedForEncodings = 0; 375 | knownRegions = ( 376 | en, 377 | Base, 378 | ); 379 | mainGroup = B58F25DB2B96E58E000CAD57; 380 | packageReferences = ( 381 | B53800D32C1C88DE001C5AB8 /* XCRemoteSwiftPackageReference "swift-testing" */, 382 | ); 383 | productRefGroup = B58F25E52B96E58E000CAD57 /* Products */; 384 | projectDirPath = ""; 385 | projectRoot = ""; 386 | targets = ( 387 | B58F25E32B96E58E000CAD57 /* OnlineStoreMV */, 388 | B559E6AC2B9AF88D002218EB /* OnlineStoreMVTests */, 389 | B5660BC02CAC50C200380A27 /* OnlineStoreMVUITests */, 390 | ); 391 | }; 392 | /* End PBXProject section */ 393 | 394 | /* Begin PBXResourcesBuildPhase section */ 395 | B559E6AB2B9AF88D002218EB /* Resources */ = { 396 | isa = PBXResourcesBuildPhase; 397 | buildActionMask = 2147483647; 398 | files = ( 399 | ); 400 | runOnlyForDeploymentPostprocessing = 0; 401 | }; 402 | B5660BBF2CAC50C200380A27 /* Resources */ = { 403 | isa = PBXResourcesBuildPhase; 404 | buildActionMask = 2147483647; 405 | files = ( 406 | ); 407 | runOnlyForDeploymentPostprocessing = 0; 408 | }; 409 | B58F25E22B96E58E000CAD57 /* Resources */ = { 410 | isa = PBXResourcesBuildPhase; 411 | buildActionMask = 2147483647; 412 | files = ( 413 | B58F25F02B96E58F000CAD57 /* Preview Assets.xcassets in Resources */, 414 | B58F25EC2B96E58F000CAD57 /* Assets.xcassets in Resources */, 415 | ); 416 | runOnlyForDeploymentPostprocessing = 0; 417 | }; 418 | /* End PBXResourcesBuildPhase section */ 419 | 420 | /* Begin PBXSourcesBuildPhase section */ 421 | B559E6A92B9AF88D002218EB /* Sources */ = { 422 | isa = PBXSourcesBuildPhase; 423 | buildActionMask = 2147483647; 424 | files = ( 425 | B559E6B92B9AF8C3002218EB /* CartStoreTest.swift in Sources */, 426 | B57B56B62C66E1FF00D90D0F /* ProductStoreTest.swift in Sources */, 427 | ); 428 | runOnlyForDeploymentPostprocessing = 0; 429 | }; 430 | B5660BBD2CAC50C200380A27 /* Sources */ = { 431 | isa = PBXSourcesBuildPhase; 432 | buildActionMask = 2147483647; 433 | files = ( 434 | B5660BD12CAC514F00380A27 /* OnlineStoreMV_UITests.swift in Sources */, 435 | ); 436 | runOnlyForDeploymentPostprocessing = 0; 437 | }; 438 | B58F25E02B96E58E000CAD57 /* Sources */ = { 439 | isa = PBXSourcesBuildPhase; 440 | buildActionMask = 2147483647; 441 | files = ( 442 | B559E6882B97A6EE002218EB /* ProductStore.swift in Sources */, 443 | B50DF7242C98FF8000DF0C4D /* Logger.swift in Sources */, 444 | B559E69D2B9995B8002218EB /* CartCell.swift in Sources */, 445 | B559E69F2B9A66D7002218EB /* UserProfile.swift in Sources */, 446 | B559E6A72B9A7B5F002218EB /* RootView.swift in Sources */, 447 | B559E6782B96EDFC002218EB /* ProductCell.swift in Sources */, 448 | B559E6A42B9A67B9002218EB /* AccountStore.swift in Sources */, 449 | B559E68B2B982E6E002218EB /* APIClient.swift in Sources */, 450 | B50DF7202C98EAD000DF0C4D /* InMemoryStrategy.swift in Sources */, 451 | B559E6962B984D84002218EB /* ProductList.swift in Sources */, 452 | B559E6A22B9A671B002218EB /* ProfileView.swift in Sources */, 453 | B50DFF3E2BE6A3CB00039CCE /* CartItem+Sample.swift in Sources */, 454 | B50DFF3C2BE6A25800039CCE /* Product+Sample.swift in Sources */, 455 | B559E69B2B9994AD002218EB /* CartListView.swift in Sources */, 456 | B50DF7192C98634B00DF0C4D /* DiscountCalculator.swift in Sources */, 457 | B51F118D2BE29AD20005C4DC /* ProductErrorView.swift in Sources */, 458 | B50DF7222C98ED7F00DF0C4D /* FileLoggingStrategy.swift in Sources */, 459 | B559E6862B978624002218EB /* CartItem.swift in Sources */, 460 | B50DF71E2C98E9BE00DF0C4D /* LoggingStrategy.swift in Sources */, 461 | B50DF71B2C98658100DF0C4D /* DiscountProvider.swift in Sources */, 462 | B559E6842B97856E002218EB /* CartStore.swift in Sources */, 463 | B58F25E82B96E58E000CAD57 /* OnlineStoreMVApp.swift in Sources */, 464 | B559E6762B96EDD3002218EB /* Product.swift in Sources */, 465 | B559E67D2B977B43002218EB /* PlusMinusButton.swift in Sources */, 466 | B559E67A2B977A45002218EB /* AddToCartButton.swift in Sources */, 467 | B50DF7162C93258100DF0C4D /* DatabaseClient.swift in Sources */, 468 | ); 469 | runOnlyForDeploymentPostprocessing = 0; 470 | }; 471 | /* End PBXSourcesBuildPhase section */ 472 | 473 | /* Begin PBXTargetDependency section */ 474 | B559E6B22B9AF88D002218EB /* PBXTargetDependency */ = { 475 | isa = PBXTargetDependency; 476 | target = B58F25E32B96E58E000CAD57 /* OnlineStoreMV */; 477 | targetProxy = B559E6B12B9AF88D002218EB /* PBXContainerItemProxy */; 478 | }; 479 | B5660BC82CAC50C200380A27 /* PBXTargetDependency */ = { 480 | isa = PBXTargetDependency; 481 | target = B58F25E32B96E58E000CAD57 /* OnlineStoreMV */; 482 | targetProxy = B5660BC72CAC50C200380A27 /* PBXContainerItemProxy */; 483 | }; 484 | /* End PBXTargetDependency section */ 485 | 486 | /* Begin XCBuildConfiguration section */ 487 | B559E6B42B9AF88D002218EB /* Debug */ = { 488 | isa = XCBuildConfiguration; 489 | buildSettings = { 490 | BUNDLE_LOADER = "$(TEST_HOST)"; 491 | CODE_SIGN_STYLE = Automatic; 492 | CURRENT_PROJECT_VERSION = 1; 493 | DEVELOPMENT_TEAM = LHJA6J73E9; 494 | GENERATE_INFOPLIST_FILE = YES; 495 | IPHONEOS_DEPLOYMENT_TARGET = 17.2; 496 | MARKETING_VERSION = 1.0; 497 | PRODUCT_BUNDLE_IDENTIFIER = com.swiftandtips.OnlineStoreMVTests; 498 | PRODUCT_NAME = "$(TARGET_NAME)"; 499 | SDKROOT = iphoneos; 500 | SWIFT_EMIT_LOC_STRINGS = NO; 501 | SWIFT_VERSION = 5.0; 502 | TARGETED_DEVICE_FAMILY = "1,2"; 503 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OnlineStoreMV.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/OnlineStoreMV"; 504 | }; 505 | name = Debug; 506 | }; 507 | B559E6B52B9AF88D002218EB /* Release */ = { 508 | isa = XCBuildConfiguration; 509 | buildSettings = { 510 | BUNDLE_LOADER = "$(TEST_HOST)"; 511 | CODE_SIGN_STYLE = Automatic; 512 | CURRENT_PROJECT_VERSION = 1; 513 | DEVELOPMENT_TEAM = LHJA6J73E9; 514 | GENERATE_INFOPLIST_FILE = YES; 515 | IPHONEOS_DEPLOYMENT_TARGET = 17.2; 516 | MARKETING_VERSION = 1.0; 517 | PRODUCT_BUNDLE_IDENTIFIER = com.swiftandtips.OnlineStoreMVTests; 518 | PRODUCT_NAME = "$(TARGET_NAME)"; 519 | SDKROOT = iphoneos; 520 | SWIFT_EMIT_LOC_STRINGS = NO; 521 | SWIFT_VERSION = 5.0; 522 | TARGETED_DEVICE_FAMILY = "1,2"; 523 | TEST_HOST = "$(BUILT_PRODUCTS_DIR)/OnlineStoreMV.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/OnlineStoreMV"; 524 | VALIDATE_PRODUCT = YES; 525 | }; 526 | name = Release; 527 | }; 528 | B5660BC92CAC50C200380A27 /* Debug */ = { 529 | isa = XCBuildConfiguration; 530 | buildSettings = { 531 | CLANG_ENABLE_MODULES = YES; 532 | CODE_SIGN_STYLE = Automatic; 533 | CURRENT_PROJECT_VERSION = 1; 534 | DEVELOPMENT_TEAM = LHJA6J73E9; 535 | GENERATE_INFOPLIST_FILE = YES; 536 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 537 | MARKETING_VERSION = 1.0; 538 | PRODUCT_BUNDLE_IDENTIFIER = com.swiftandtips.OnlineStoreMVUITests; 539 | PRODUCT_NAME = "$(TARGET_NAME)"; 540 | SDKROOT = iphoneos; 541 | SWIFT_EMIT_LOC_STRINGS = NO; 542 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 543 | SWIFT_VERSION = 5.0; 544 | TARGETED_DEVICE_FAMILY = "1,2"; 545 | TEST_TARGET_NAME = OnlineStoreMV; 546 | }; 547 | name = Debug; 548 | }; 549 | B5660BCA2CAC50C200380A27 /* Release */ = { 550 | isa = XCBuildConfiguration; 551 | buildSettings = { 552 | CLANG_ENABLE_MODULES = YES; 553 | CODE_SIGN_STYLE = Automatic; 554 | CURRENT_PROJECT_VERSION = 1; 555 | DEVELOPMENT_TEAM = LHJA6J73E9; 556 | GENERATE_INFOPLIST_FILE = YES; 557 | IPHONEOS_DEPLOYMENT_TARGET = 18.0; 558 | MARKETING_VERSION = 1.0; 559 | PRODUCT_BUNDLE_IDENTIFIER = com.swiftandtips.OnlineStoreMVUITests; 560 | PRODUCT_NAME = "$(TARGET_NAME)"; 561 | SDKROOT = iphoneos; 562 | SWIFT_EMIT_LOC_STRINGS = NO; 563 | SWIFT_VERSION = 5.0; 564 | TARGETED_DEVICE_FAMILY = "1,2"; 565 | TEST_TARGET_NAME = OnlineStoreMV; 566 | VALIDATE_PRODUCT = YES; 567 | }; 568 | name = Release; 569 | }; 570 | B58F25F12B96E58F000CAD57 /* Debug */ = { 571 | isa = XCBuildConfiguration; 572 | buildSettings = { 573 | ALWAYS_SEARCH_USER_PATHS = NO; 574 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 575 | CLANG_ANALYZER_NONNULL = YES; 576 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 577 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 578 | CLANG_ENABLE_MODULES = YES; 579 | CLANG_ENABLE_OBJC_ARC = YES; 580 | CLANG_ENABLE_OBJC_WEAK = YES; 581 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 582 | CLANG_WARN_BOOL_CONVERSION = YES; 583 | CLANG_WARN_COMMA = YES; 584 | CLANG_WARN_CONSTANT_CONVERSION = YES; 585 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 586 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 587 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 588 | CLANG_WARN_EMPTY_BODY = YES; 589 | CLANG_WARN_ENUM_CONVERSION = YES; 590 | CLANG_WARN_INFINITE_RECURSION = YES; 591 | CLANG_WARN_INT_CONVERSION = YES; 592 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 593 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 594 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 595 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 596 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 597 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 598 | CLANG_WARN_STRICT_PROTOTYPES = YES; 599 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 600 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 601 | CLANG_WARN_UNREACHABLE_CODE = YES; 602 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 603 | COPY_PHASE_STRIP = NO; 604 | DEBUG_INFORMATION_FORMAT = dwarf; 605 | ENABLE_STRICT_OBJC_MSGSEND = YES; 606 | ENABLE_TESTABILITY = YES; 607 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 608 | GCC_C_LANGUAGE_STANDARD = gnu17; 609 | GCC_DYNAMIC_NO_PIC = NO; 610 | GCC_NO_COMMON_BLOCKS = YES; 611 | GCC_OPTIMIZATION_LEVEL = 0; 612 | GCC_PREPROCESSOR_DEFINITIONS = ( 613 | "DEBUG=1", 614 | "$(inherited)", 615 | ); 616 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 617 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 618 | GCC_WARN_UNDECLARED_SELECTOR = YES; 619 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 620 | GCC_WARN_UNUSED_FUNCTION = YES; 621 | GCC_WARN_UNUSED_VARIABLE = YES; 622 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 623 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 624 | MTL_FAST_MATH = YES; 625 | ONLY_ACTIVE_ARCH = YES; 626 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; 627 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 628 | }; 629 | name = Debug; 630 | }; 631 | B58F25F22B96E58F000CAD57 /* Release */ = { 632 | isa = XCBuildConfiguration; 633 | buildSettings = { 634 | ALWAYS_SEARCH_USER_PATHS = NO; 635 | ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; 636 | CLANG_ANALYZER_NONNULL = YES; 637 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 638 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 639 | CLANG_ENABLE_MODULES = YES; 640 | CLANG_ENABLE_OBJC_ARC = YES; 641 | CLANG_ENABLE_OBJC_WEAK = YES; 642 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 643 | CLANG_WARN_BOOL_CONVERSION = YES; 644 | CLANG_WARN_COMMA = YES; 645 | CLANG_WARN_CONSTANT_CONVERSION = YES; 646 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 647 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 648 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 649 | CLANG_WARN_EMPTY_BODY = YES; 650 | CLANG_WARN_ENUM_CONVERSION = YES; 651 | CLANG_WARN_INFINITE_RECURSION = YES; 652 | CLANG_WARN_INT_CONVERSION = YES; 653 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 654 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 655 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 656 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 657 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 658 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 659 | CLANG_WARN_STRICT_PROTOTYPES = YES; 660 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 661 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 662 | CLANG_WARN_UNREACHABLE_CODE = YES; 663 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 664 | COPY_PHASE_STRIP = NO; 665 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 666 | ENABLE_NS_ASSERTIONS = NO; 667 | ENABLE_STRICT_OBJC_MSGSEND = YES; 668 | ENABLE_USER_SCRIPT_SANDBOXING = YES; 669 | GCC_C_LANGUAGE_STANDARD = gnu17; 670 | GCC_NO_COMMON_BLOCKS = YES; 671 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 672 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 673 | GCC_WARN_UNDECLARED_SELECTOR = YES; 674 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 675 | GCC_WARN_UNUSED_FUNCTION = YES; 676 | GCC_WARN_UNUSED_VARIABLE = YES; 677 | LOCALIZATION_PREFERS_STRING_CATALOGS = YES; 678 | MTL_ENABLE_DEBUG_INFO = NO; 679 | MTL_FAST_MATH = YES; 680 | SWIFT_COMPILATION_MODE = wholemodule; 681 | }; 682 | name = Release; 683 | }; 684 | B58F25F42B96E58F000CAD57 /* Debug */ = { 685 | isa = XCBuildConfiguration; 686 | buildSettings = { 687 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 688 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 689 | CODE_SIGN_ENTITLEMENTS = OnlineStoreMV/OnlineStoreMV.entitlements; 690 | CODE_SIGN_STYLE = Automatic; 691 | CURRENT_PROJECT_VERSION = 1; 692 | DEVELOPMENT_ASSET_PATHS = "\"OnlineStoreMV/Preview Content\""; 693 | DEVELOPMENT_TEAM = LHJA6J73E9; 694 | ENABLE_HARDENED_RUNTIME = YES; 695 | ENABLE_PREVIEWS = YES; 696 | GENERATE_INFOPLIST_FILE = YES; 697 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 698 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 699 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 700 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 701 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 702 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 703 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 704 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 705 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 706 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 707 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 708 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 709 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 710 | MACOSX_DEPLOYMENT_TARGET = 14.0; 711 | MARKETING_VERSION = 1.0; 712 | PRODUCT_BUNDLE_IDENTIFIER = com.swiftandtips.OnlineStoreMV; 713 | PRODUCT_NAME = "$(TARGET_NAME)"; 714 | SDKROOT = auto; 715 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 716 | SWIFT_EMIT_LOC_STRINGS = YES; 717 | SWIFT_VERSION = 5.0; 718 | TARGETED_DEVICE_FAMILY = "1,2"; 719 | }; 720 | name = Debug; 721 | }; 722 | B58F25F52B96E58F000CAD57 /* Release */ = { 723 | isa = XCBuildConfiguration; 724 | buildSettings = { 725 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 726 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 727 | CODE_SIGN_ENTITLEMENTS = OnlineStoreMV/OnlineStoreMV.entitlements; 728 | CODE_SIGN_STYLE = Automatic; 729 | CURRENT_PROJECT_VERSION = 1; 730 | DEVELOPMENT_ASSET_PATHS = "\"OnlineStoreMV/Preview Content\""; 731 | DEVELOPMENT_TEAM = LHJA6J73E9; 732 | ENABLE_HARDENED_RUNTIME = YES; 733 | ENABLE_PREVIEWS = YES; 734 | GENERATE_INFOPLIST_FILE = YES; 735 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; 736 | "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; 737 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; 738 | "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; 739 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; 740 | "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; 741 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; 742 | "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; 743 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 744 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 745 | IPHONEOS_DEPLOYMENT_TARGET = 17.0; 746 | LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; 747 | "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; 748 | MACOSX_DEPLOYMENT_TARGET = 14.0; 749 | MARKETING_VERSION = 1.0; 750 | PRODUCT_BUNDLE_IDENTIFIER = com.swiftandtips.OnlineStoreMV; 751 | PRODUCT_NAME = "$(TARGET_NAME)"; 752 | SDKROOT = auto; 753 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; 754 | SWIFT_EMIT_LOC_STRINGS = YES; 755 | SWIFT_VERSION = 5.0; 756 | TARGETED_DEVICE_FAMILY = "1,2"; 757 | }; 758 | name = Release; 759 | }; 760 | /* End XCBuildConfiguration section */ 761 | 762 | /* Begin XCConfigurationList section */ 763 | B559E6B32B9AF88D002218EB /* Build configuration list for PBXNativeTarget "OnlineStoreMVTests" */ = { 764 | isa = XCConfigurationList; 765 | buildConfigurations = ( 766 | B559E6B42B9AF88D002218EB /* Debug */, 767 | B559E6B52B9AF88D002218EB /* Release */, 768 | ); 769 | defaultConfigurationIsVisible = 0; 770 | defaultConfigurationName = Release; 771 | }; 772 | B5660BCB2CAC50C200380A27 /* Build configuration list for PBXNativeTarget "OnlineStoreMVUITests" */ = { 773 | isa = XCConfigurationList; 774 | buildConfigurations = ( 775 | B5660BC92CAC50C200380A27 /* Debug */, 776 | B5660BCA2CAC50C200380A27 /* Release */, 777 | ); 778 | defaultConfigurationIsVisible = 0; 779 | defaultConfigurationName = Release; 780 | }; 781 | B58F25DF2B96E58E000CAD57 /* Build configuration list for PBXProject "OnlineStoreMV" */ = { 782 | isa = XCConfigurationList; 783 | buildConfigurations = ( 784 | B58F25F12B96E58F000CAD57 /* Debug */, 785 | B58F25F22B96E58F000CAD57 /* Release */, 786 | ); 787 | defaultConfigurationIsVisible = 0; 788 | defaultConfigurationName = Release; 789 | }; 790 | B58F25F32B96E58F000CAD57 /* Build configuration list for PBXNativeTarget "OnlineStoreMV" */ = { 791 | isa = XCConfigurationList; 792 | buildConfigurations = ( 793 | B58F25F42B96E58F000CAD57 /* Debug */, 794 | B58F25F52B96E58F000CAD57 /* Release */, 795 | ); 796 | defaultConfigurationIsVisible = 0; 797 | defaultConfigurationName = Release; 798 | }; 799 | /* End XCConfigurationList section */ 800 | 801 | /* Begin XCRemoteSwiftPackageReference section */ 802 | B53800D32C1C88DE001C5AB8 /* XCRemoteSwiftPackageReference "swift-testing" */ = { 803 | isa = XCRemoteSwiftPackageReference; 804 | repositoryURL = "https://github.com/apple/swift-testing"; 805 | requirement = { 806 | kind = upToNextMajorVersion; 807 | minimumVersion = 0.10.0; 808 | }; 809 | }; 810 | /* End XCRemoteSwiftPackageReference section */ 811 | 812 | /* Begin XCSwiftPackageProductDependency section */ 813 | B53800D42C1C88DE001C5AB8 /* Testing */ = { 814 | isa = XCSwiftPackageProductDependency; 815 | package = B53800D32C1C88DE001C5AB8 /* XCRemoteSwiftPackageReference "swift-testing" */; 816 | productName = Testing; 817 | }; 818 | /* End XCSwiftPackageProductDependency section */ 819 | }; 820 | rootObject = B58F25DC2B96E58E000CAD57 /* Project object */; 821 | } 822 | --------------------------------------------------------------------------------