From 3b1754f662ada21db584d7311f333a017d638da3 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:16:33 +0200 Subject: [PATCH 01/11] Create Cart struct to support both Products and Coupons Evolved Cart structure to contain Items and Coupons to resemble Order that contains both OrderItems and CouponLineItems. Abstracted both types of items under CartItem which shares an id for now. Created helper add, remove, removeAll, isEmpty methods to more easily switch from current functionality --- WooCommerce/Classes/POS/Models/Cart.swift | 65 +++++++++++++++++++ WooCommerce/Classes/POS/Models/CartItem.swift | 10 --- .../WooCommerce.xcodeproj/project.pbxproj | 8 +-- 3 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 WooCommerce/Classes/POS/Models/Cart.swift delete mode 100644 WooCommerce/Classes/POS/Models/CartItem.swift diff --git a/WooCommerce/Classes/POS/Models/Cart.swift b/WooCommerce/Classes/POS/Models/Cart.swift new file mode 100644 index 00000000000..5c36762c296 --- /dev/null +++ b/WooCommerce/Classes/POS/Models/Cart.swift @@ -0,0 +1,65 @@ +import Foundation +import protocol Yosemite.POSOrderableItem +import enum Yosemite.POSItem + +struct Cart { + var items: [CartProductItem] = [] + var coupons: [CartCouponItem] = [] +} + +protocol CartItem: Identifiable { + var id: UUID { get } +} + +struct CartProductItem: CartItem { + let id: UUID + let item: POSOrderableItem + let title: String + let subtitle: String? + let quantity: Int +} + +struct CartCouponItem: CartItem { + let id: UUID + let code: String +} + +// MARK: - Helper Methods + +extension Cart { + mutating func add(_ posItem: POSItem) { + switch posItem { + case .simpleProduct(let simpleProduct): + let productItem = CartProductItem(id: UUID(), item: simpleProduct, title: simpleProduct.name, subtitle: nil, quantity: 1) + items.insert(productItem, at: items.startIndex) + case .variation(let variation): + let productItem = CartProductItem(id: UUID(), item: variation, title: variation.parentProductName, subtitle: variation.name, quantity: 1) + items.insert(productItem, at: items.startIndex) + case .variableParentProduct: + return + case .coupon(let coupon): + let couponItem = CartCouponItem(id: UUID(), code: coupon.code) + coupons.insert(couponItem, at: items.startIndex) + } + } + + mutating func remove(_ cartItem: any CartItem) { + switch cartItem { + case _ as CartProductItem: + items.removeAll { $0.id == cartItem.id } + case _ as CartCouponItem: + coupons.removeAll { $0.id == cartItem.id } + default: + break + } + } + + mutating func removeAll() { + items.removeAll() + coupons.removeAll() + } + + var isEmpty: Bool { + items.isEmpty && coupons.isEmpty + } +} diff --git a/WooCommerce/Classes/POS/Models/CartItem.swift b/WooCommerce/Classes/POS/Models/CartItem.swift deleted file mode 100644 index ee09f298ebd..00000000000 --- a/WooCommerce/Classes/POS/Models/CartItem.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation -import protocol Yosemite.POSOrderableItem - -struct CartItem { - let id: UUID - let item: POSOrderableItem - let title: String - let subtitle: String? - let quantity: Int -} diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 432e74091c5..7c1adf4f907 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1632,7 +1632,7 @@ 68E952D0287587BF0095A23D /* CardReaderManualRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E952CF287587BF0095A23D /* CardReaderManualRowView.swift */; }; 68E952D22875A44B0095A23D /* CardReaderType+Manual.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68E952D12875A44B0095A23D /* CardReaderType+Manual.swift */; }; 68ED2BD62ADD2C8C00ECA88D /* LineDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68ED2BD52ADD2C8C00ECA88D /* LineDetailView.swift */; }; - 68F151E12C0DA7910082AEC8 /* CartItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68F151E02C0DA7910082AEC8 /* CartItem.swift */; }; + 68F151E12C0DA7910082AEC8 /* Cart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68F151E02C0DA7910082AEC8 /* Cart.swift */; }; 68F68A502D6730E200BB9568 /* POSCollectOrderPaymentAnalyticsTracking.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68F68A4F2D6730DF00BB9568 /* POSCollectOrderPaymentAnalyticsTracking.swift */; }; 68F68A522D67365900BB9568 /* MockPOSCollectOrderPaymentAnalyticsTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68F68A512D67365900BB9568 /* MockPOSCollectOrderPaymentAnalyticsTracker.swift */; }; 68F896422D5E4323000B308B /* POSCollectOrderPaymentAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68F896412D5E4321000B308B /* POSCollectOrderPaymentAnalytics.swift */; }; @@ -4808,7 +4808,7 @@ 68E952CF287587BF0095A23D /* CardReaderManualRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualRowView.swift; sourceTree = ""; }; 68E952D12875A44B0095A23D /* CardReaderType+Manual.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CardReaderType+Manual.swift"; sourceTree = ""; }; 68ED2BD52ADD2C8C00ECA88D /* LineDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LineDetailView.swift; sourceTree = ""; }; - 68F151E02C0DA7910082AEC8 /* CartItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CartItem.swift; sourceTree = ""; }; + 68F151E02C0DA7910082AEC8 /* Cart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cart.swift; sourceTree = ""; }; 68F68A4F2D6730DF00BB9568 /* POSCollectOrderPaymentAnalyticsTracking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSCollectOrderPaymentAnalyticsTracking.swift; sourceTree = ""; }; 68F68A512D67365900BB9568 /* MockPOSCollectOrderPaymentAnalyticsTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockPOSCollectOrderPaymentAnalyticsTracker.swift; sourceTree = ""; }; 68F896412D5E4321000B308B /* POSCollectOrderPaymentAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSCollectOrderPaymentAnalytics.swift; sourceTree = ""; }; @@ -9929,7 +9929,7 @@ 20F7B12E2D12CBE700C08193 /* ItemsViewState.swift */, 20C909952D3151FA0013BCCF /* ItemListBaseItem.swift */, 20D4AE002D133B43004555B2 /* ItemsStackState.swift */, - 68F151E02C0DA7910082AEC8 /* CartItem.swift */, + 68F151E02C0DA7910082AEC8 /* Cart.swift */, 20D920E92CEF86520023B089 /* PointOfSaleErrorState.swift */, 2044158C2CE4DB480070BF54 /* PointOfSaleOrderStage.swift */, 2044158E2CE6181E0070BF54 /* PointOfSaleOrderState.swift */, @@ -16578,7 +16578,7 @@ 57C5FF7F250925C90074EC26 /* OrderListViewModel.swift in Sources */, 029D02602C231F5F00CB1E75 /* PointOfSaleCardPresentPaymentReaderUpdateCompletionView.swift in Sources */, 26E0AE13263359F900A5EB3B /* View+Conditionals.swift in Sources */, - 68F151E12C0DA7910082AEC8 /* CartItem.swift in Sources */, + 68F151E12C0DA7910082AEC8 /* Cart.swift in Sources */, CE583A072107849F00D73C1C /* SwitchTableViewCell.swift in Sources */, EE7E75A82D83EB1F00E6FF5B /* WooShippingSplitShipmentsRow.swift in Sources */, D8149F562251EE300006A245 /* UITextField+Helpers.swift in Sources */, From d3d1f1a3496d338ae6d081359f0039e763e0f02b Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:27:22 +0200 Subject: [PATCH 02/11] Use Cart instead of CartItems in PointOfSaleOrderController --- .../PointOfSaleOrderController.swift | 6 +- .../PointOfSalePreviewOrderController.swift | 2 +- .../PointOfSaleOrderControllerTests.swift | 60 +++++++++---------- .../MockPointOfSaleOrderController.swift | 6 +- 4 files changed, 37 insertions(+), 37 deletions(-) diff --git a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift index ba128eafb77..0f01b2626e1 100644 --- a/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift +++ b/WooCommerce/Classes/POS/Controllers/PointOfSaleOrderController.swift @@ -27,7 +27,7 @@ protocol PointOfSaleOrderControllerProtocol { var orderState: PointOfSaleInternalOrderState { get } @discardableResult - func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async -> Result + func syncOrder(for cart: Cart, retryHandler: @escaping () async -> Void) async -> Result func sendReceipt(recipientEmail: String) async throws func clearOrder() func collectCashPayment() async throws @@ -63,9 +63,9 @@ protocol PointOfSaleOrderControllerProtocol { private var order: Order? = nil @MainActor @discardableResult - func syncOrder(for cartItems: [CartItem], + func syncOrder(for cart: Cart, retryHandler: @escaping () async -> Void) async -> Result { - let posCartItems = cartItems.map { + let posCartItems = cart.items.map { POSCartItem(item: $0.item, quantity: Decimal($0.quantity)) } diff --git a/WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift b/WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift index df5536dddd0..c73b9053ade 100644 --- a/WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift +++ b/WooCommerce/Classes/POS/Utils/PointOfSalePreviewOrderController.swift @@ -12,7 +12,7 @@ class PointOfSalePreviewOrderController: PointOfSaleOrderControllerProtocol { OrderFactory.newOrder(currency: .USD) ) - func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async -> Result { + func syncOrder(for cart: Cart, retryHandler: @escaping () async -> Void) async -> Result { return .success(.newOrder) } diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift index a61e34738d2..a830030d879 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift @@ -20,7 +20,7 @@ struct PointOfSaleOrderControllerTests { receiptService: mockReceiptService) // When - await sut.syncOrder(for: [], retryHandler: {}) + await sut.syncOrder(for: .init(), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == false) @@ -35,12 +35,12 @@ struct PointOfSaleOrderControllerTests { let fakeOrder = Order.fake().copy(items: [orderItem]) let cartItem = makeItem(orderItemsToMatch: [orderItem]) mockOrderService.orderToReturn = fakeOrder - await sut.syncOrder(for: [cartItem], retryHandler: {}) + await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {}) mockOrderService.syncOrderWasCalled = false // When - await sut.syncOrder(for: [cartItem], retryHandler: {}) + await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {}) // Then #expect(mockOrderService.syncOrderWasCalled == false) @@ -53,14 +53,14 @@ struct PointOfSaleOrderControllerTests { receiptService: mockReceiptService) mockOrderService.simulateSyncing = true Task { - await sut.syncOrder(for: [makeItem(quantity: 1)], retryHandler: {}) + await sut.syncOrder(for: .init(items: [makeItem(quantity: 1)]), retryHandler: {}) } try await Task.sleep(nanoseconds: UInt64(100 * Double(NSEC_PER_MSEC))) mockOrderService.syncOrderWasCalled = false // When - await sut.syncOrder(for: [makeItem(quantity: 2), - makeItem(quantity: 5)], + await sut.syncOrder(for: .init(items: [makeItem(quantity: 2), + makeItem(quantity: 5)]), retryHandler: {}) // Then @@ -80,7 +80,7 @@ struct PointOfSaleOrderControllerTests { currencySettings: currencySettings) // When - await sut.syncOrder(for: [makeItem()], retryHandler: {}) + await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {}) // Then #expect(mockOrderService.spySyncOrderCurrency == .AUD) @@ -99,8 +99,8 @@ struct PointOfSaleOrderControllerTests { let futureOrderItem = OrderItem.fake().copy(quantity: 5) // When - await sut.syncOrder(for: [cartItem, - makeItem(quantity: 5, orderItemsToMatch: [futureOrderItem])], + await sut.syncOrder(for: .init(items: [cartItem, + makeItem(quantity: 5, orderItemsToMatch: [futureOrderItem])]), retryHandler: {}) // Then @@ -131,7 +131,7 @@ struct PointOfSaleOrderControllerTests { observeOrderState() // When - await sut.syncOrder(for: [makeItem()], retryHandler: {}) + await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {}) } await orderStateAppendTask?.value @@ -169,7 +169,7 @@ struct PointOfSaleOrderControllerTests { observeOrderState() // When - await sut.syncOrder(for: [makeItem()], retryHandler: {}) + await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {}) } await orderStateAppendTask?.value @@ -209,7 +209,7 @@ struct PointOfSaleOrderControllerTests { mockOrderService.orderToReturn = order // We need an existing order before we can update its email, and send a receipt: - await sut.syncOrder(for: [makeItem()], retryHandler: { }) + await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: { }) // When try await sut.sendReceipt(recipientEmail: recipientEmail) @@ -249,7 +249,7 @@ struct PointOfSaleOrderControllerTests { let orderItem = OrderItem.fake() let fakeOrder = Order.fake().copy(items: [orderItem]) mockOrderService.orderToReturn = fakeOrder - await sut.syncOrder(for: [makeItem()], retryHandler: {}) + await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {}) // When let completionResult: Bool = await withCheckedContinuation { continuation in @@ -287,7 +287,7 @@ struct PointOfSaleOrderControllerTests { mockOrderService.orderToReturn = fakeOrder // When - let result = await sut.syncOrder(for: [fakeCartItem], retryHandler: { }) + let result = await sut.syncOrder(for: .init(items: [fakeCartItem]), retryHandler: { }) // Then if case .success(let state) = result { @@ -308,10 +308,10 @@ struct PointOfSaleOrderControllerTests { // When // 1. Initial order - _ = await sut.syncOrder(for: [makeItem()], retryHandler: {}) + _ = await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {}) // 2. Sync existing order - let result = await sut.syncOrder(for: [makeItem(), makeItem()], retryHandler: {}) + let result = await sut.syncOrder(for: .init(items: [makeItem(), makeItem()]), retryHandler: {}) // Then if case .success(let state) = result { @@ -333,10 +333,10 @@ struct PointOfSaleOrderControllerTests { // When // 1. Initial order - _ = await sut.syncOrder(for: [cartItem], retryHandler: {}) + _ = await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {}) // 2. Syncing existing order with same cart should not update order - let result = await sut.syncOrder(for: [cartItem], retryHandler: {}) + let result = await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {}) // Then if case .success(let state) = result { @@ -354,7 +354,7 @@ struct PointOfSaleOrderControllerTests { // When mockOrderService.orderToReturn = nil - let result = await sut.syncOrder(for: [cartItem], retryHandler: {}) + let result = await sut.syncOrder(for: .init(items: [cartItem]), retryHandler: {}) // Then if case .failure(let error) = result { @@ -386,7 +386,7 @@ struct PointOfSaleOrderControllerTests { orderService.orderToReturn = fakeOrder // When - await sut.syncOrder(for: [fakeCartItem], retryHandler: { }) + await sut.syncOrder(for: .init(items: [fakeCartItem]), retryHandler: { }) // Then #expect(analyticsProvider.receivedEvents.first(where: { $0 == "order_creation_success" }) != nil) @@ -401,7 +401,7 @@ struct PointOfSaleOrderControllerTests { orderService.orderToReturn = nil // When - await sut.syncOrder(for: [makeItem()], retryHandler: {}) + await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {}) // Then #expect(analyticsProvider.receivedEvents.first(where: { $0 == "order_creation_failed" }) != nil) @@ -429,7 +429,7 @@ struct PointOfSaleOrderControllerTests { let orderItem = OrderItem.fake() let fakeOrder = Order.fake().copy(items: [orderItem]) mockOrderService.orderToReturn = fakeOrder - await sut.syncOrder(for: [makeItem()], retryHandler: {}) + await sut.syncOrder(for: .init(items: [makeItem()]), retryHandler: {}) // When await withCheckedContinuation { continuation in @@ -456,12 +456,12 @@ struct PointOfSaleOrderControllerTests { private func makeItem(name: String = "", formattedPrice: String = "", quantity: Int = 1, - orderItemsToMatch: [OrderItem] = []) -> CartItem { - return CartItem(id: UUID(), - item: MockPOSOrderableItem(name: name, - formattedPrice: formattedPrice, - orderItemsToMatch: orderItemsToMatch), - title: name, - subtitle: nil, - quantity: quantity) + orderItemsToMatch: [OrderItem] = []) -> CartProductItem { + return CartProductItem(id: UUID(), + item: MockPOSOrderableItem(name: name, + formattedPrice: formattedPrice, + orderItemsToMatch: orderItemsToMatch), + title: name, + subtitle: nil, + quantity: quantity) } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift index 93a282d17fb..90c90eed4e4 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift @@ -16,15 +16,15 @@ final class MockPointOfSaleOrderController: PointOfSaleOrderControllerProtocol { var orderStateToReturn: PointOfSaleInternalOrderState? var syncOrderWasCalled: Bool = false - var spyCartProducts: [CartItem]? + var spyCartProducts: [CartProductItem]? var spyRetryHandler: (() async -> Void)? var syncOrderResultToReturn: Result = .success(.newOrder) @discardableResult - func syncOrder(for cartProducts: [CartItem], + func syncOrder(for cart: Cart, retryHandler: @escaping () async -> Void) async -> Result { syncOrderWasCalled = true - spyCartProducts = cartProducts + spyCartProducts = cart.items spyRetryHandler = retryHandler guard let orderStateToReturn else { From f293ea4251463e4d855392e7534fa9d3dfec8704 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:28:52 +0200 Subject: [PATCH 03/11] Update PointOfSaleAggregateModel to use Cart --- .../Models/PointOfSaleAggregateModel.swift | 34 +++-------- ...rdPresentPaymentsOnboardingViewModel.swift | 3 +- .../Mocks/MockPointOfSaleAggregateModel.swift | 4 +- .../PointOfSaleAggregateModelTests.swift | 57 +++++++++++++++---- 4 files changed, 57 insertions(+), 41 deletions(-) diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift index 1b2e86d038f..695568c09de 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift @@ -6,7 +6,7 @@ import protocol Yosemite.POSOrderableItem import protocol WooFoundation.Analytics import struct Yosemite.Order import struct Yosemite.OrderItem -import struct Yosemite.POSCartItem +import struct Yosemite.POSCoupon import enum Yosemite.POSItem import enum Yosemite.SystemStatusAction @@ -28,9 +28,9 @@ protocol PointOfSaleAggregateModelProtocol { func loadItems(base: ItemListBaseItem) async func loadNextItems(base: ItemListBaseItem) async - var cart: [CartItem] { get } + var cart: Cart { get } func addToCart(_ item: POSItem) - func remove(cartItem: CartItem) + func remove(cartItem: any CartItem) func removeAllItemsFromCart() func addMoreToCart() func startNewCart() @@ -54,7 +54,7 @@ protocol PointOfSaleAggregateModelProtocol { var itemsViewState: ItemsViewState { itemsController.itemsViewState } - private(set) var cart: [CartItem] = [] + private(set) var cart: Cart = .init() var orderState: PointOfSaleOrderState { orderController.orderState.externalState } private var internalOrderState: PointOfSaleInternalOrderState { orderController.orderState } @@ -108,33 +108,15 @@ extension PointOfSaleAggregateModel { } } -// MARK: - Cart - -private extension POSItem { - var cartItem: CartItem? { - switch self { - case .simpleProduct(let simpleProduct): - return CartItem(id: UUID(), item: simpleProduct, title: simpleProduct.name, subtitle: nil, quantity: 1) - case .variation(let variation): - return CartItem(id: UUID(), item: variation, title: variation.parentProductName, subtitle: variation.name, quantity: 1) - case .variableParentProduct: - return nil - case .coupon: - return nil - } - } -} - @available(iOS 17.0, *) extension PointOfSaleAggregateModel { func addToCart(_ item: POSItem) { trackCustomerInteractionStarted() - guard let cartItem = item.cartItem else { return } - cart.insert(cartItem, at: cart.startIndex) + cart.add(item) } - func remove(cartItem: CartItem) { - cart.removeAll(where: { $0.id == cartItem.id } ) + func remove(cartItem: any CartItem) { + cart.remove(cartItem) } func removeAllItemsFromCart() { @@ -164,7 +146,7 @@ private extension PointOfSaleAggregateModel { func trackCustomerInteractionStarted() { // At the moment we're assumming that an interaction starts simply when the cart is zero // but a more complex logic will be needed for other cases - if cart.count == 0 { + if cart.isEmpty { collectOrderPaymentAnalyticsTracker.trackCustomerInteractionStarted() } } diff --git a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingViewModel.swift b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingViewModel.swift index c077b617d30..6f9ffd02911 100644 --- a/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingViewModel.swift +++ b/WooCommerce/Classes/ViewRelated/Dashboard/Settings/In-Person Payments/CardPresentPaymentsOnboardingViewModel.swift @@ -45,10 +45,11 @@ final class CardPresentPaymentsOnboardingViewModel: ObservableObject, PaymentSet init( fixedState: CardPresentPaymentOnboardingState, fixedUserIsAdministrator: Bool = false, + useCase: CardPresentPaymentsOnboardingUseCaseProtocol = CardPresentPaymentsOnboardingUseCase(), stores: StoresManager = ServiceLocator.stores) { self.stores = stores state = fixedState - useCase = CardPresentPaymentsOnboardingUseCase() + self.useCase = useCase userIsAdministrator = fixedUserIsAdministrator updateLearnMoreURL(state: fixedState) } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift index 3ec34e0191d..29391e64008 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift @@ -47,11 +47,11 @@ final class MockPointOfSaleAggregateModel: PointOfSaleAggregateModelProtocol { func loadNextItems(base: ItemListBaseItem) async { } - var cart: [CartItem] = [] + var cart: Cart = .init() func addToCart(_ item: POSItem) { } - func remove(cartItem: CartItem) { } + func remove(cartItem: any CartItem) { } var removeAllItemsFromCartCalled = false func removeAllItemsFromCart() { diff --git a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift index 8e6962e44a3..971403d8758 100644 --- a/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Models/PointOfSaleAggregateModelTests.swift @@ -15,6 +15,7 @@ struct PointOfSaleAggregateModelTests { let sut = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(), cardPresentPaymentService: MockCardPresentPaymentService(), orderController: MockPointOfSaleOrderController(), + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) // Then #expect(sut.orderStage == .building) @@ -26,11 +27,12 @@ struct PointOfSaleAggregateModelTests { let sut = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(), cardPresentPaymentService: MockCardPresentPaymentService(), orderController: MockPointOfSaleOrderController(), + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) sut.addToCart(makeItem()) await sut.checkOut() try #require(sut.orderStage == .finalizing) - try #require(sut.cart.isNotEmpty) + try #require(!sut.cart.isEmpty) // When sut.startNewCart() @@ -46,6 +48,7 @@ struct PointOfSaleAggregateModelTests { let sut = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(), cardPresentPaymentService: MockCardPresentPaymentService(), orderController: MockPointOfSaleOrderController(), + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) sut.addToCart(makeItem()) @@ -62,6 +65,7 @@ struct PointOfSaleAggregateModelTests { let sut = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(), cardPresentPaymentService: MockCardPresentPaymentService(), orderController: MockPointOfSaleOrderController(), + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) sut.addToCart(makeItem()) await sut.checkOut() @@ -100,7 +104,7 @@ struct PointOfSaleAggregateModelTests { sut.addToCart(item) // Then - #expect(sut.cart.isNotEmpty) + #expect(!sut.cart.isEmpty) } @available(iOS 17.0, *) @@ -117,7 +121,7 @@ struct PointOfSaleAggregateModelTests { items.forEach(sut.addToCart(_:)) // Then - #expect(sut.cart.map(\.item.id) == items.reversed().map(\.id)) + #expect(sut.cart.items.map(\.item.id) == items.reversed().map(\.id)) } @available(iOS 17.0, *) @@ -133,15 +137,15 @@ struct PointOfSaleAggregateModelTests { sut.addToCart(item) sut.addToCart(anotherItem) - try #require(sut.cart.count == 2) + try #require(sut.cart.items.count == 2) // When - let firstItem = try #require(sut.cart.first) + let firstItem = try #require(sut.cart.items.first) sut.remove(cartItem: firstItem) // Then - #expect(sut.cart.count == 1) - #expect(sut.cart.first?.title == "Item 1") + #expect(sut.cart.items.count == 1) + #expect(sut.cart.items.first?.title == "Item 1") } @available(iOS 17.0, *) @@ -157,7 +161,7 @@ struct PointOfSaleAggregateModelTests { sut.addToCart(item) sut.addToCart(anotherItem) - try #require(sut.cart.count == 2) + try #require(sut.cart.items.count == 2) // When sut.removeAllItemsFromCart() @@ -207,6 +211,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) sut.addToCart(makeItem()) @@ -226,11 +231,12 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) sut.addToCart(makeItem()) sut.addToCart(makeItem()) - let item = try #require(sut.cart.first) + let item = try #require(sut.cart.items.first) // When await sut.checkOut() @@ -248,6 +254,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) sut.addToCart(makeItem()) @@ -269,6 +276,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) sut.addToCart(makeItem()) @@ -290,6 +298,7 @@ struct PointOfSaleAggregateModelTests { let sut = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(), cardPresentPaymentService: MockCardPresentPaymentService(), orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) // When @@ -309,6 +318,7 @@ struct PointOfSaleAggregateModelTests { let sut = PointOfSaleAggregateModel(itemsController: MockPointOfSaleItemsController(), cardPresentPaymentService: MockCardPresentPaymentService(), orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) do { @@ -330,6 +340,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) sut.addToCart(makeItem()) @@ -356,6 +367,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) // Then @@ -388,6 +400,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) cardPresentPaymentService.paymentEvent = .show(eventDetails: .paymentSuccess(done: {})) @@ -426,6 +439,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) cardPresentPaymentService.paymentEvent = .show( @@ -449,6 +463,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) // When @@ -467,6 +482,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) // When @@ -484,6 +500,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) await sut.startCashPayment() #expect(sut.paymentState == .cash(.collectingCash)) @@ -503,6 +520,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) #expect(sut.orderStage == .building) @@ -532,6 +550,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) // When @@ -553,6 +572,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) struct TestError: Error {} @@ -580,6 +600,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) struct TestError: Error {} await sut.checkOut() @@ -610,6 +631,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) cardPresentPaymentService.connectedReader = nil @@ -639,6 +661,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) cardPresentPaymentService.connectedReader = .init(name: "Test reader", batteryLevel: 0.7) orderController.orderStateToReturn = makeLoadedOrderState(orderTotal: "$0.01", orderTotalDecimal: 0.01) @@ -658,6 +681,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) cardPresentPaymentService.connectedReader = .init(name: "Test reader", batteryLevel: 0.7) orderController.orderStateToReturn = makeLoadedOrderState(orderTotal: "$0.00", orderTotalDecimal: 0.0) @@ -677,6 +701,7 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) cardPresentPaymentService.connectedReader = CardPresentPaymentCardReader(name: "Test", batteryLevel: 0.5) @@ -707,9 +732,10 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) orderController.orderStateToReturn = makeLoadedOrderState(cartTotal: "$1.00") - await orderController.syncOrder(for: [], retryHandler: {}) + await orderController.syncOrder(for: .init(), retryHandler: {}) struct TestError: Error {} cardPresentPaymentService.onCancelPaymentCalled = { @@ -732,8 +758,12 @@ struct PointOfSaleAggregateModelTests { itemsController: itemsController, cardPresentPaymentService: cardPresentPaymentService, orderController: orderController, + analytics: WooAnalytics(analyticsProvider: MockAnalyticsProvider()), collectOrderPaymentAnalyticsTracker: MockPOSCollectOrderPaymentAnalyticsTracker()) - let onboardingViewModel = CardPresentPaymentsOnboardingViewModel(fixedState: .pluginNotActivated(plugin: .stripe)) + let onboardingViewModel = CardPresentPaymentsOnboardingViewModel( + fixedState: .pluginNotActivated(plugin: .stripe), + useCase: MockCardPresentPaymentsOnboardingUseCase(initial: .pluginNotActivated(plugin: .stripe)) + ) cardPresentPaymentService.paymentEvent = .idle try #require(sut.cardPresentPaymentOnboardingViewModel == nil) @@ -769,7 +799,10 @@ struct PointOfSaleAggregateModelTests { sut.addToCart(makeItem()) - let onboardingViewModel = CardPresentPaymentsOnboardingViewModel(fixedState: .noConnectionError) + let onboardingViewModel = CardPresentPaymentsOnboardingViewModel( + fixedState: .noConnectionError, + useCase: MockCardPresentPaymentsOnboardingUseCase(initial: .noConnectionError) + ) cardPresentPaymentService.paymentEvent = .showOnboarding(onboardingViewModel: onboardingViewModel, onCancel: {}) // When From 34bea544c0047ba9b449871f0e5a4217077feec1 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 24 Mar 2025 17:33:49 +0200 Subject: [PATCH 04/11] Update CartView to use Cart --- .../Classes/POS/Presentation/CartView.swift | 14 +++++------ .../POS/Presentation/ItemListView.swift | 1 - .../POS/Presentation/ItemRowView.swift | 24 +++++++++---------- .../POS/ViewHelpers/CartViewHelper.swift | 4 ++-- .../POS/ViewHelpers/CartViewHelperTests.swift | 20 ++++++++-------- 5 files changed, 31 insertions(+), 32 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/CartView.swift b/WooCommerce/Classes/POS/Presentation/CartView.swift index f4b6b43204b..c785d31b307 100644 --- a/WooCommerce/Classes/POS/Presentation/CartView.swift +++ b/WooCommerce/Classes/POS/Presentation/CartView.swift @@ -11,7 +11,7 @@ struct CartView: View { @State private var offSetPosition: CGFloat = 0.0 private var coordinateSpace: CoordinateSpace = .named(Constants.scrollViewCoordinateSpaceIdentifier) private var shouldApplyHeaderBottomShadow: Bool { - posModel.cart.isNotEmpty && offSetPosition < 0 + !posModel.cart.isEmpty && offSetPosition < 0 } @State private var shouldShowItemImages: Bool = false @@ -22,7 +22,7 @@ struct CartView: View { backButtonConfiguration: backButtonConfiguration, trailingContent: { DynamicHStack(horizontalAlignment: .trailing, verticalAlignment: .center, spacing: Constants.cartHeaderElementSpacing) { - if let itemsInCartLabel = viewHelper.itemsInCartLabel(for: posModel.cart.count) { + if let itemsInCartLabel = viewHelper.itemsInCartLabel(for: posModel.cart.items.count) { Text(itemsInCartLabel) .font(Constants.itemsFont) .lineLimit(1) @@ -43,11 +43,11 @@ struct CartView: View { }) .if(shouldApplyHeaderBottomShadow, transform: { $0.applyBottomShadow(backgroundColor: backgroundColor) }) - if posModel.cart.isNotEmpty { + if !posModel.cart.isEmpty { ScrollViewReader { proxy in ScrollView { VStack(spacing: Constants.cartItemSpacing) { - ForEach(posModel.cart, id: \.id) { cartItem in + ForEach(posModel.cart.items, id: \.id) { cartItem in ItemRowView(cartItem: cartItem, showImage: $shouldShowItemImages, onItemRemoveTapped: posModel.orderStage == .building ? { @@ -58,7 +58,7 @@ struct CartView: View { .transition(.opacity) } } - .animation(Constants.cartAnimation, value: posModel.cart.map(\.id)) + .animation(Constants.cartAnimation, value: posModel.cart.items.map(\.id)) .background(GeometryReader { geometry in Color.clear.preference(key: ScrollOffSetPreferenceKey.self, value: geometry.frame(in: coordinateSpace).origin.y) @@ -81,7 +81,7 @@ struct CartView: View { .renderedIf(posModel.orderStage == .finalizing) } .coordinateSpace(name: Constants.scrollViewCoordinateSpaceIdentifier) - .onChange(of: posModel.cart.first?.id) { itemToScrollTo in + .onChange(of: posModel.cart.items.first?.id) { itemToScrollTo in if posModel.orderStage == .building { withAnimation { proxy.scrollTo(itemToScrollTo) @@ -268,7 +268,7 @@ private extension CartView { @available(iOS 17.0, *) private extension CartView { func trackCheckoutTapped() { - let itemsInCart = posModel.cart.count + let itemsInCart = posModel.cart.items.count ServiceLocator.analytics.track(event: .PointOfSale.checkoutTapped(itemsInCart)) } } diff --git a/WooCommerce/Classes/POS/Presentation/ItemListView.swift b/WooCommerce/Classes/POS/Presentation/ItemListView.swift index 808cbcadffa..d197d124e09 100644 --- a/WooCommerce/Classes/POS/Presentation/ItemListView.swift +++ b/WooCommerce/Classes/POS/Presentation/ItemListView.swift @@ -1,6 +1,5 @@ import SwiftUI import enum Yosemite.POSItem -import protocol Yosemite.POSOrderableItem @available(iOS 17.0, *) struct ItemListView: View { diff --git a/WooCommerce/Classes/POS/Presentation/ItemRowView.swift b/WooCommerce/Classes/POS/Presentation/ItemRowView.swift index a33ffa8c536..0ec9634531e 100644 --- a/WooCommerce/Classes/POS/Presentation/ItemRowView.swift +++ b/WooCommerce/Classes/POS/Presentation/ItemRowView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ItemRowView: View { - private let cartItem: CartItem + private let cartItem: CartProductItem private let onItemRemoveTapped: (() -> Void)? @ScaledMetric private var scale: CGFloat = 1.0 @@ -11,7 +11,7 @@ struct ItemRowView: View { min(Constants.productCardSize * scale, Constants.maximumProductCardSize) } - init(cartItem: CartItem, showImage: Binding = .constant(true), onItemRemoveTapped: (() -> Void)? = nil) { + init(cartItem: CartProductItem, showImage: Binding = .constant(true), onItemRemoveTapped: (() -> Void)? = nil) { self.cartItem = cartItem self._showProductImage = showImage self.onItemRemoveTapped = onItemRemoveTapped @@ -94,21 +94,21 @@ private extension ItemRowView { #if DEBUG @available(iOS 17.0, *) #Preview(traits: .sizeThatFitsLayout) { - ItemRowView(cartItem: CartItem(id: UUID(), - item: PointOfSalePreviewItemService().providePointOfSaleItem(), - title: "Item Title", - subtitle: "Item Subtitle", - quantity: 2), + ItemRowView(cartItem: CartProductItem(id: UUID(), + item: PointOfSalePreviewItemService().providePointOfSaleItem(), + title: "Item Title", + subtitle: "Item Subtitle", + quantity: 2), onItemRemoveTapped: { }) } @available(iOS 17.0, *) #Preview(traits: .sizeThatFitsLayout) { - ItemRowView(cartItem: CartItem(id: UUID(), - item: PointOfSalePreviewItemService().providePointOfSaleItem(), - title: "Item Title", - subtitle: nil, - quantity: 2), + ItemRowView(cartItem: CartProductItem(id: UUID(), + item: PointOfSalePreviewItemService().providePointOfSaleItem(), + title: "Item Title", + subtitle: nil, + quantity: 2), onItemRemoveTapped: { }) } #endif diff --git a/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift b/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift index 736295dddeb..6612fb6cce9 100644 --- a/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift +++ b/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift @@ -18,8 +18,8 @@ final class CartViewHelper { return orderState.isSyncing } - func shouldShowClearCartButton(cart: [CartItem], orderStage: PointOfSaleOrderStage) -> Bool { - cart.isNotEmpty && orderStage == .building + func shouldShowClearCartButton(cart: Cart, orderStage: PointOfSaleOrderStage) -> Bool { + !cart.isEmpty && orderStage == .building } } diff --git a/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift b/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift index 63120b9fbf7..2953c7dec9f 100644 --- a/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift +++ b/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift @@ -74,23 +74,23 @@ struct CartViewHelperTests { } @Test func shouldShowClearCartButton_empty_cart_false() async throws { - #expect(sut.shouldShowClearCartButton(cart: [], orderStage: .building) == false) - #expect(sut.shouldShowClearCartButton(cart: [], orderStage: .finalizing) == false) + #expect(sut.shouldShowClearCartButton(cart: .init(), orderStage: .building) == false) + #expect(sut.shouldShowClearCartButton(cart: .init(), orderStage: .finalizing) == false) } @Test func shouldShowClearCartButton_items_in_cart_and_building_true() async throws { - #expect(sut.shouldShowClearCartButton(cart: [makeItem()], orderStage: .building) == true) + #expect(sut.shouldShowClearCartButton(cart: .init(items: [makeItem()]), orderStage: .building) == true) } @Test func shouldShowClearCartButton_items_in_cart_and_finalizing_false() async throws { - #expect(sut.shouldShowClearCartButton(cart: [makeItem()], orderStage: .finalizing) == false) + #expect(sut.shouldShowClearCartButton(cart: .init(items: [makeItem()]), orderStage: .finalizing) == false) } } -private func makeItem() -> CartItem { - CartItem(id: UUID(), - item: MockPOSOrderableItem(name: "Item", formattedPrice: "$1.00"), - title: "Item", - subtitle: nil, - quantity: 1) +private func makeItem() -> CartProductItem { + CartProductItem(id: UUID(), + item: MockPOSOrderableItem(name: "Item", formattedPrice: "$1.00"), + title: "Item", + subtitle: nil, + quantity: 1) } From 11e49a933a44e12a64be359158158a024700d755 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 24 Mar 2025 18:28:58 +0200 Subject: [PATCH 05/11] Add CouponRowView with dummy design for displaying coupon in Cart --- .../POS/Presentation/CouponRowView.swift | 65 +++++++++++++++++++ .../WooCommerce.xcodeproj/project.pbxproj | 4 ++ 2 files changed, 69 insertions(+) create mode 100644 WooCommerce/Classes/POS/Presentation/CouponRowView.swift diff --git a/WooCommerce/Classes/POS/Presentation/CouponRowView.swift b/WooCommerce/Classes/POS/Presentation/CouponRowView.swift new file mode 100644 index 00000000000..73f1fb6c048 --- /dev/null +++ b/WooCommerce/Classes/POS/Presentation/CouponRowView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct CouponRowView: View { + private let couponItem: CartCouponItem + private let onItemRemoveTapped: (() -> Void)? + + @ScaledMetric private var scale: CGFloat = 1.0 + + init(couponItem: CartCouponItem, onItemRemoveTapped: (() -> Void)? = nil) { + self.couponItem = couponItem + self.onItemRemoveTapped = onItemRemoveTapped + } + + var body: some View { + HStack(spacing: Constants.horizontalElementSpacing) { + Rectangle() + .foregroundColor(.posSurfaceDim) + .overlay { + Text(Image(systemName: "tag.square.fill")) + .font(.posButtonSymbolLarge) + .foregroundColor(.posOnSurfaceVariantLowest) + } + .frame(width: Constants.couponCardSize, height: Constants.couponCardSize) + + VStack(alignment: .leading) { + Text(couponItem.code) + .foregroundColor(PointOfSaleItemListCardConstants.titleColor) + .font(Constants.itemTitleFont) + } + .frame(maxWidth: .infinity, alignment: .leading) + + if let onItemRemoveTapped { + Button(action: { + onItemRemoveTapped() + }, label: { + Text(Image(systemName: "xmark.circle")) + .font(.posButtonSymbolMedium) + }) + .foregroundColor(Color.posOnSurfaceVariantLowest) + } + } + .padding(.trailing, Constants.cardContentHorizontalPadding) + .frame(maxWidth: .infinity, idealHeight: Constants.couponCardSize * scale) + .background(Color.posSurfaceContainerLowest) + .posItemCardBorderStyles() + .padding(.horizontal, Constants.horizontalPadding) + } +} + +private extension CouponRowView { + enum Constants { + static let couponCardSize: CGFloat = 96 + static let horizontalPadding: CGFloat = POSPadding.medium + static let horizontalElementSpacing: CGFloat = POSSpacing.medium + static let cardContentHorizontalPadding: CGFloat = POSPadding.medium + static let itemTitleFont: POSFontStyle = .posBodySmallBold + } +} + +#if DEBUG +@available(iOS 17.0, *) +#Preview(traits: .sizeThatFitsLayout) { + CouponRowView(couponItem: CartCouponItem(id: UUID(), code: "10-Discount")) {} +} +#endif diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 7c1adf4f907..8d2f3d20d50 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -27,6 +27,7 @@ 011D7A352CEC87B70007C187 /* CardPresentModalErrorEmailSent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011D7A342CEC87B60007C187 /* CardPresentModalErrorEmailSent.swift */; }; 011DF3442C53A5CF000AFDD9 /* PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DF3432C53A5CF000AFDD9 /* PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift */; }; 011DF3462C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 011DF3452C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift */; }; + 0139BB522D91B45800C78FDE /* CouponRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0139BB512D91B45500C78FDE /* CouponRowView.swift */; }; 013D2FB42CFEFEC600845D75 /* BuiltInCardReaderMerchantEducationPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013D2FB32CFEFEA800845D75 /* BuiltInCardReaderMerchantEducationPresenter.swift */; }; 013D2FB62CFF54BB00845D75 /* TapToPayEducationStepsFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 013D2FB52CFF54B600845D75 /* TapToPayEducationStepsFactory.swift */; }; 014BD4B82C64E2BA0011A66E /* PointOfSaleOrderSyncErrorMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 014BD4B72C64E2BA0011A66E /* PointOfSaleOrderSyncErrorMessageView.swift */; }; @@ -3259,6 +3260,7 @@ 011D7A342CEC87B60007C187 /* CardPresentModalErrorEmailSent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardPresentModalErrorEmailSent.swift; sourceTree = ""; }; 011DF3432C53A5CF000AFDD9 /* PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentValidatingOrderMessageViewModel.swift; sourceTree = ""; }; 011DF3452C53A919000AFDD9 /* PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleCardPresentPaymentActivityIndicatingMessageView.swift; sourceTree = ""; }; + 0139BB512D91B45500C78FDE /* CouponRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CouponRowView.swift; sourceTree = ""; }; 013D2FB32CFEFEA800845D75 /* BuiltInCardReaderMerchantEducationPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BuiltInCardReaderMerchantEducationPresenter.swift; sourceTree = ""; }; 013D2FB52CFF54B600845D75 /* TapToPayEducationStepsFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TapToPayEducationStepsFactory.swift; sourceTree = ""; }; 014BD4B72C64E2BA0011A66E /* PointOfSaleOrderSyncErrorMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderSyncErrorMessageView.swift; sourceTree = ""; }; @@ -7242,6 +7244,7 @@ 68C53CBD2C1FE59B00C6D80B /* ItemListView.swift */, 026826A32BF59DF60036F959 /* CartView.swift */, 026826A22BF59DF60036F959 /* ItemRowView.swift */, + 0139BB512D91B45500C78FDE /* CouponRowView.swift */, 68D8FBD02BFEF9C700477C42 /* TotalsView.swift */, 68AF3C3A2D01481A006F1ED2 /* POSReceiptEligibilityBanner.swift */, DA1D68C12C36F0980097859A /* PointOfSaleAssets.swift */, @@ -16081,6 +16084,7 @@ E1E636BB26FB467A00C9D0D7 /* Comparable+Woo.swift in Sources */, CE315DC42CC91A4A00A06748 /* WooShippingServiceViewModel.swift in Sources */, 450C2CB024CF006A00D570DD /* ProductTagsDataSource.swift in Sources */, + 0139BB522D91B45800C78FDE /* CouponRowView.swift in Sources */, DEB3879E2C34FE620025256E /* GoogleAdsCampaignCoordinator.swift in Sources */, EE45E2BA2A409BA40085F227 /* TooltipPresenter.swift in Sources */, 023D69BC2589BF5900F7DA72 /* PrintShippingLabelCoordinator.swift in Sources */, From d5563c2d2436170538a1bc5ab817425dc88c8afb Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 24 Mar 2025 18:29:28 +0200 Subject: [PATCH 06/11] Add dummy coupons to CartView --- .../Classes/POS/Presentation/CartView.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/WooCommerce/Classes/POS/Presentation/CartView.swift b/WooCommerce/Classes/POS/Presentation/CartView.swift index c785d31b307..38a0db5028a 100644 --- a/WooCommerce/Classes/POS/Presentation/CartView.swift +++ b/WooCommerce/Classes/POS/Presentation/CartView.swift @@ -47,6 +47,21 @@ struct CartView: View { ScrollViewReader { proxy in ScrollView { VStack(spacing: Constants.cartItemSpacing) { + + /// WIP: Behind the feature flag + if posModel.cart.coupons.isNotEmpty { + ForEach(posModel.cart.coupons, id: \.id) { couponItem in + CouponRowView(couponItem: couponItem, + onItemRemoveTapped: posModel.orderStage == .building ? { + posModel.remove(cartItem: couponItem) + } : nil) + .id(couponItem.id) + .transition(.opacity) + } + + Spacer(minLength: 64) + } + ForEach(posModel.cart.items, id: \.id) { cartItem in ItemRowView(cartItem: cartItem, showImage: $shouldShowItemImages, @@ -59,6 +74,7 @@ struct CartView: View { } } .animation(Constants.cartAnimation, value: posModel.cart.items.map(\.id)) + .animation(Constants.cartAnimation, value: posModel.cart.coupons.map(\.id)) .background(GeometryReader { geometry in Color.clear.preference(key: ScrollOffSetPreferenceKey.self, value: geometry.frame(in: coordinateSpace).origin.y) From fac9b490f5d050cd0d9b0de0948bafa0bc6ee281 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 24 Mar 2025 18:57:00 +0200 Subject: [PATCH 07/11] Use coupon code in POSCoupon --- Yosemite/Yosemite/PointOfSale/POSCoupon.swift | 7 ++++++- Yosemite/Yosemite/PointOfSale/PointOfSaleItemService.swift | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/Yosemite/Yosemite/PointOfSale/POSCoupon.swift b/Yosemite/Yosemite/PointOfSale/POSCoupon.swift index f66255829e6..1abdfcc90b0 100644 --- a/Yosemite/Yosemite/PointOfSale/POSCoupon.swift +++ b/Yosemite/Yosemite/PointOfSale/POSCoupon.swift @@ -1,4 +1,9 @@ public struct POSCoupon: Equatable, Hashable { public let id: UUID - public let couponID: Int64 + public let code: String + + public init(id: UUID, code: String) { + self.id = id + self.code = code + } } diff --git a/Yosemite/Yosemite/PointOfSale/PointOfSaleItemService.swift b/Yosemite/Yosemite/PointOfSale/PointOfSaleItemService.swift index da8bb81a0c1..4b9e13a6ec1 100644 --- a/Yosemite/Yosemite/PointOfSale/PointOfSaleItemService.swift +++ b/Yosemite/Yosemite/PointOfSale/PointOfSaleItemService.swift @@ -125,7 +125,7 @@ public final class PointOfSaleItemService: PointOfSaleItemServiceProtocol { private func mapCouponsToPOSItems(coupons: [Coupon]) -> [POSItem] { coupons.compactMap { coupon in - .coupon(POSCoupon(id: UUID(), couponID: coupon.couponID)) + .coupon(POSCoupon(id: UUID(), code: coupon.code)) } } From 61e34230b8438a7c3f3045db29808dc3bec49072 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Mon, 24 Mar 2025 20:37:17 +0200 Subject: [PATCH 08/11] Insert coupons at coupons start index --- WooCommerce/Classes/POS/Models/Cart.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WooCommerce/Classes/POS/Models/Cart.swift b/WooCommerce/Classes/POS/Models/Cart.swift index 5c36762c296..a04c13711f2 100644 --- a/WooCommerce/Classes/POS/Models/Cart.swift +++ b/WooCommerce/Classes/POS/Models/Cart.swift @@ -39,7 +39,7 @@ extension Cart { return case .coupon(let coupon): let couponItem = CartCouponItem(id: UUID(), code: coupon.code) - coupons.insert(couponItem, at: items.startIndex) + coupons.insert(couponItem, at: coupons.startIndex) } } From 94d203ad8eb1eaac7ea7fc81ec928a1db56f1c34 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:44:50 +0200 Subject: [PATCH 09/11] Rename CartProductItem back to CartItem, remove the protocol --- WooCommerce/Classes/POS/Models/Cart.swift | 29 +++++++------------ .../Models/PointOfSaleAggregateModel.swift | 9 ++++-- .../Classes/POS/Presentation/CartView.swift | 2 +- .../POS/Presentation/ItemRowView.swift | 26 ++++++++--------- .../PointOfSaleOrderControllerTests.swift | 18 ++++++------ .../Mocks/MockPointOfSaleAggregateModel.swift | 4 ++- .../MockPointOfSaleOrderController.swift | 2 +- .../POS/ViewHelpers/CartViewHelperTests.swift | 12 ++++---- 8 files changed, 51 insertions(+), 51 deletions(-) diff --git a/WooCommerce/Classes/POS/Models/Cart.swift b/WooCommerce/Classes/POS/Models/Cart.swift index a04c13711f2..d825fc44b74 100644 --- a/WooCommerce/Classes/POS/Models/Cart.swift +++ b/WooCommerce/Classes/POS/Models/Cart.swift @@ -3,15 +3,11 @@ import protocol Yosemite.POSOrderableItem import enum Yosemite.POSItem struct Cart { - var items: [CartProductItem] = [] + var items: [CartItem] = [] var coupons: [CartCouponItem] = [] } -protocol CartItem: Identifiable { - var id: UUID { get } -} - -struct CartProductItem: CartItem { +struct CartItem { let id: UUID let item: POSOrderableItem let title: String @@ -19,7 +15,7 @@ struct CartProductItem: CartItem { let quantity: Int } -struct CartCouponItem: CartItem { +struct CartCouponItem { let id: UUID let code: String } @@ -30,10 +26,10 @@ extension Cart { mutating func add(_ posItem: POSItem) { switch posItem { case .simpleProduct(let simpleProduct): - let productItem = CartProductItem(id: UUID(), item: simpleProduct, title: simpleProduct.name, subtitle: nil, quantity: 1) + let productItem = CartItem(id: UUID(), item: simpleProduct, title: simpleProduct.name, subtitle: nil, quantity: 1) items.insert(productItem, at: items.startIndex) case .variation(let variation): - let productItem = CartProductItem(id: UUID(), item: variation, title: variation.parentProductName, subtitle: variation.name, quantity: 1) + let productItem = CartItem(id: UUID(), item: variation, title: variation.parentProductName, subtitle: variation.name, quantity: 1) items.insert(productItem, at: items.startIndex) case .variableParentProduct: return @@ -43,15 +39,12 @@ extension Cart { } } - mutating func remove(_ cartItem: any CartItem) { - switch cartItem { - case _ as CartProductItem: - items.removeAll { $0.id == cartItem.id } - case _ as CartCouponItem: - coupons.removeAll { $0.id == cartItem.id } - default: - break - } + mutating func remove(_ cartItem: CartItem) { + items.removeAll { $0.id == cartItem.id } + } + + mutating func remove(_ cartCouponItem: CartCouponItem) { + coupons.removeAll { $0.id == cartCouponItem.id } } mutating func removeAll() { diff --git a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift index 695568c09de..6f65bedc293 100644 --- a/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift +++ b/WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift @@ -30,7 +30,8 @@ protocol PointOfSaleAggregateModelProtocol { var cart: Cart { get } func addToCart(_ item: POSItem) - func remove(cartItem: any CartItem) + func remove(cartItem: CartItem) + func remove(cartCouponItem: CartCouponItem) func removeAllItemsFromCart() func addMoreToCart() func startNewCart() @@ -115,10 +116,14 @@ extension PointOfSaleAggregateModel { cart.add(item) } - func remove(cartItem: any CartItem) { + func remove(cartItem: CartItem) { cart.remove(cartItem) } + func remove(cartCouponItem: CartCouponItem) { + cart.remove(cartCouponItem) + } + func removeAllItemsFromCart() { cart.removeAll() } diff --git a/WooCommerce/Classes/POS/Presentation/CartView.swift b/WooCommerce/Classes/POS/Presentation/CartView.swift index 38a0db5028a..818220296a4 100644 --- a/WooCommerce/Classes/POS/Presentation/CartView.swift +++ b/WooCommerce/Classes/POS/Presentation/CartView.swift @@ -53,7 +53,7 @@ struct CartView: View { ForEach(posModel.cart.coupons, id: \.id) { couponItem in CouponRowView(couponItem: couponItem, onItemRemoveTapped: posModel.orderStage == .building ? { - posModel.remove(cartItem: couponItem) + posModel.remove(cartCouponItem: couponItem) } : nil) .id(couponItem.id) .transition(.opacity) diff --git a/WooCommerce/Classes/POS/Presentation/ItemRowView.swift b/WooCommerce/Classes/POS/Presentation/ItemRowView.swift index 0ec9634531e..d042c50e1c2 100644 --- a/WooCommerce/Classes/POS/Presentation/ItemRowView.swift +++ b/WooCommerce/Classes/POS/Presentation/ItemRowView.swift @@ -1,7 +1,7 @@ import SwiftUI struct ItemRowView: View { - private let cartItem: CartProductItem + private let cartItem: CartItem private let onItemRemoveTapped: (() -> Void)? @ScaledMetric private var scale: CGFloat = 1.0 @@ -11,7 +11,7 @@ struct ItemRowView: View { min(Constants.productCardSize * scale, Constants.maximumProductCardSize) } - init(cartItem: CartProductItem, showImage: Binding = .constant(true), onItemRemoveTapped: (() -> Void)? = nil) { + init(cartItem: CartItem, showImage: Binding = .constant(true), onItemRemoveTapped: (() -> Void)? = nil) { self.cartItem = cartItem self._showProductImage = showImage self.onItemRemoveTapped = onItemRemoveTapped @@ -81,7 +81,7 @@ private extension ItemRowView { static let itemSubtitleFont: POSFontStyle = .posBodySmallRegular() static let itemPriceFont: POSFontStyle = .posBodySmallRegular() } - + enum Localization { static let removeFromCartAccessibilityLabel = NSLocalizedString( "pointOfSale.item.removeFromCart.button.accessibilityLabel", @@ -94,21 +94,21 @@ private extension ItemRowView { #if DEBUG @available(iOS 17.0, *) #Preview(traits: .sizeThatFitsLayout) { - ItemRowView(cartItem: CartProductItem(id: UUID(), - item: PointOfSalePreviewItemService().providePointOfSaleItem(), - title: "Item Title", - subtitle: "Item Subtitle", - quantity: 2), + ItemRowView(cartItem: CartItem(id: UUID(), + item: PointOfSalePreviewItemService().providePointOfSaleItem(), + title: "Item Title", + subtitle: "Item Subtitle", + quantity: 2), onItemRemoveTapped: { }) } @available(iOS 17.0, *) #Preview(traits: .sizeThatFitsLayout) { - ItemRowView(cartItem: CartProductItem(id: UUID(), - item: PointOfSalePreviewItemService().providePointOfSaleItem(), - title: "Item Title", - subtitle: nil, - quantity: 2), + ItemRowView(cartItem: CartItem(id: UUID(), + item: PointOfSalePreviewItemService().providePointOfSaleItem(), + title: "Item Title", + subtitle: nil, + quantity: 2), onItemRemoveTapped: { }) } #endif diff --git a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift index a830030d879..798def4dfd3 100644 --- a/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift +++ b/WooCommerce/WooCommerceTests/POS/Controllers/PointOfSaleOrderControllerTests.swift @@ -100,7 +100,7 @@ struct PointOfSaleOrderControllerTests { // When await sut.syncOrder(for: .init(items: [cartItem, - makeItem(quantity: 5, orderItemsToMatch: [futureOrderItem])]), + makeItem(quantity: 5, orderItemsToMatch: [futureOrderItem])]), retryHandler: {}) // Then @@ -456,12 +456,12 @@ struct PointOfSaleOrderControllerTests { private func makeItem(name: String = "", formattedPrice: String = "", quantity: Int = 1, - orderItemsToMatch: [OrderItem] = []) -> CartProductItem { - return CartProductItem(id: UUID(), - item: MockPOSOrderableItem(name: name, - formattedPrice: formattedPrice, - orderItemsToMatch: orderItemsToMatch), - title: name, - subtitle: nil, - quantity: quantity) + orderItemsToMatch: [OrderItem] = []) -> CartItem { + return CartItem(id: UUID(), + item: MockPOSOrderableItem(name: name, + formattedPrice: formattedPrice, + orderItemsToMatch: orderItemsToMatch), + title: name, + subtitle: nil, + quantity: quantity) } diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift index 29391e64008..db032509607 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleAggregateModel.swift @@ -51,7 +51,9 @@ final class MockPointOfSaleAggregateModel: PointOfSaleAggregateModelProtocol { func addToCart(_ item: POSItem) { } - func remove(cartItem: any CartItem) { } + func remove(cartItem: CartItem) { } + + func remove(cartCouponItem: CartCouponItem) { } var removeAllItemsFromCartCalled = false func removeAllItemsFromCart() { diff --git a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift index 90c90eed4e4..b6fb9ff29f8 100644 --- a/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift +++ b/WooCommerce/WooCommerceTests/POS/Mocks/MockPointOfSaleOrderController.swift @@ -16,7 +16,7 @@ final class MockPointOfSaleOrderController: PointOfSaleOrderControllerProtocol { var orderStateToReturn: PointOfSaleInternalOrderState? var syncOrderWasCalled: Bool = false - var spyCartProducts: [CartProductItem]? + var spyCartProducts: [CartItem]? var spyRetryHandler: (() async -> Void)? var syncOrderResultToReturn: Result = .success(.newOrder) diff --git a/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift b/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift index 2953c7dec9f..f41cdaa5415 100644 --- a/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift +++ b/WooCommerce/WooCommerceTests/POS/ViewHelpers/CartViewHelperTests.swift @@ -87,10 +87,10 @@ struct CartViewHelperTests { } } -private func makeItem() -> CartProductItem { - CartProductItem(id: UUID(), - item: MockPOSOrderableItem(name: "Item", formattedPrice: "$1.00"), - title: "Item", - subtitle: nil, - quantity: 1) +private func makeItem() -> CartItem { + CartItem(id: UUID(), + item: MockPOSOrderableItem(name: "Item", formattedPrice: "$1.00"), + title: "Item", + subtitle: nil, + quantity: 1) } From d888004ea8db14eb763518c3cf4ddcc16589f246 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 25 Mar 2025 10:48:26 +0200 Subject: [PATCH 10/11] Add Cart isNotEmpty --- WooCommerce/Classes/POS/Models/Cart.swift | 4 ++++ WooCommerce/Classes/POS/Presentation/CartView.swift | 4 ++-- WooCommerce/Classes/POS/Presentation/ItemRowView.swift | 2 +- WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/WooCommerce/Classes/POS/Models/Cart.swift b/WooCommerce/Classes/POS/Models/Cart.swift index d825fc44b74..0ec2c437797 100644 --- a/WooCommerce/Classes/POS/Models/Cart.swift +++ b/WooCommerce/Classes/POS/Models/Cart.swift @@ -55,4 +55,8 @@ extension Cart { var isEmpty: Bool { items.isEmpty && coupons.isEmpty } + + var isNotEmpty: Bool { + return !isEmpty + } } diff --git a/WooCommerce/Classes/POS/Presentation/CartView.swift b/WooCommerce/Classes/POS/Presentation/CartView.swift index 818220296a4..bb1bb3bebd3 100644 --- a/WooCommerce/Classes/POS/Presentation/CartView.swift +++ b/WooCommerce/Classes/POS/Presentation/CartView.swift @@ -11,7 +11,7 @@ struct CartView: View { @State private var offSetPosition: CGFloat = 0.0 private var coordinateSpace: CoordinateSpace = .named(Constants.scrollViewCoordinateSpaceIdentifier) private var shouldApplyHeaderBottomShadow: Bool { - !posModel.cart.isEmpty && offSetPosition < 0 + posModel.cart.isNotEmpty && offSetPosition < 0 } @State private var shouldShowItemImages: Bool = false @@ -43,7 +43,7 @@ struct CartView: View { }) .if(shouldApplyHeaderBottomShadow, transform: { $0.applyBottomShadow(backgroundColor: backgroundColor) }) - if !posModel.cart.isEmpty { + if posModel.cart.isNotEmpty { ScrollViewReader { proxy in ScrollView { VStack(spacing: Constants.cartItemSpacing) { diff --git a/WooCommerce/Classes/POS/Presentation/ItemRowView.swift b/WooCommerce/Classes/POS/Presentation/ItemRowView.swift index d042c50e1c2..a33ffa8c536 100644 --- a/WooCommerce/Classes/POS/Presentation/ItemRowView.swift +++ b/WooCommerce/Classes/POS/Presentation/ItemRowView.swift @@ -81,7 +81,7 @@ private extension ItemRowView { static let itemSubtitleFont: POSFontStyle = .posBodySmallRegular() static let itemPriceFont: POSFontStyle = .posBodySmallRegular() } - + enum Localization { static let removeFromCartAccessibilityLabel = NSLocalizedString( "pointOfSale.item.removeFromCart.button.accessibilityLabel", diff --git a/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift b/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift index 6612fb6cce9..9a5ed3a44e1 100644 --- a/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift +++ b/WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift @@ -19,7 +19,7 @@ final class CartViewHelper { } func shouldShowClearCartButton(cart: Cart, orderStage: PointOfSaleOrderStage) -> Bool { - !cart.isEmpty && orderStage == .building + cart.isNotEmpty && orderStage == .building } } From 26b4ecef948c9a685ad6e7b2347486499450bae4 Mon Sep 17 00:00:00 2001 From: Povilas Staskus <4062343+staskus@users.noreply.github.com> Date: Tue, 25 Mar 2025 11:01:35 +0200 Subject: [PATCH 11/11] Create a separate cart section and hide it behind the feature flag --- .../Classes/POS/Presentation/CartView.swift | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/WooCommerce/Classes/POS/Presentation/CartView.swift b/WooCommerce/Classes/POS/Presentation/CartView.swift index bb1bb3bebd3..0cdd312b0c1 100644 --- a/WooCommerce/Classes/POS/Presentation/CartView.swift +++ b/WooCommerce/Classes/POS/Presentation/CartView.swift @@ -16,6 +16,10 @@ struct CartView: View { @State private var shouldShowItemImages: Bool = false + private var shouldShowCoupons: Bool { + ServiceLocator.featureFlagService.isFeatureFlagEnabled(.enableCouponsInPointOfSale) + } + var body: some View { VStack { POSPageHeaderView(title: Localization.cartTitle, @@ -47,19 +51,8 @@ struct CartView: View { ScrollViewReader { proxy in ScrollView { VStack(spacing: Constants.cartItemSpacing) { - - /// WIP: Behind the feature flag - if posModel.cart.coupons.isNotEmpty { - ForEach(posModel.cart.coupons, id: \.id) { couponItem in - CouponRowView(couponItem: couponItem, - onItemRemoveTapped: posModel.orderStage == .building ? { - posModel.remove(cartCouponItem: couponItem) - } : nil) - .id(couponItem.id) - .transition(.opacity) - } - - Spacer(minLength: 64) + if shouldShowCoupons { + couponsCartSectionView } ForEach(posModel.cart.items, id: \.id) { cartItem in @@ -109,7 +102,7 @@ struct CartView: View { Spacer() switch posModel.orderStage { case .building: - if posModel.cart.isEmpty { + if posModel.cart.items.isEmpty { EmptyView() } else { checkoutButton @@ -279,6 +272,21 @@ private extension CartView { } .background(backgroundColor.ignoresSafeArea(.all)) } + + var couponsCartSectionView: some View { + VStack { + ForEach(posModel.cart.coupons, id: \.id) { couponItem in + CouponRowView(couponItem: couponItem, + onItemRemoveTapped: posModel.orderStage == .building ? { + posModel.remove(cartCouponItem: couponItem) + } : nil) + .id(couponItem.id) + .transition(.opacity) + } + + Spacer(minLength: 48) + } + } } @available(iOS 17.0, *)