├── 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 |
--------------------------------------------------------------------------------