diff --git a/WooCommerce/Classes/POS/Presentation/ItemListView.swift b/WooCommerce/Classes/POS/Presentation/ItemListView.swift index 58d8198d496..ac5536984b0 100644 --- a/WooCommerce/Classes/POS/Presentation/ItemListView.swift +++ b/WooCommerce/Classes/POS/Presentation/ItemListView.swift @@ -1,9 +1,10 @@ import SwiftUI +import protocol Yosemite.POSItem struct ItemListView: View { - @ObservedObject var viewModel: PointOfSaleDashboardViewModel + @ObservedObject var viewModel: ItemSelectorViewModel - init(viewModel: PointOfSaleDashboardViewModel) { + init(viewModel: ItemSelectorViewModel) { self.viewModel = viewModel } @@ -22,7 +23,7 @@ struct ItemListView: View { ScrollView { ForEach(viewModel.items, id: \.productID) { item in Button(action: { - viewModel.addItemToCart(item) + viewModel.select(item) }, label: { ItemCardView(item: item) }) @@ -30,17 +31,19 @@ struct ItemListView: View { } } } + .task { + await viewModel.populatePointOfSaleItems() + } + .refreshable { + await viewModel.reload() + } .padding(.horizontal, 32) .background(Color.posBackgroundGreyi3) } } #if DEBUG -import class Yosemite.POSOrderService -import enum Yosemite.Credentials #Preview { - ItemListView(viewModel: PointOfSaleDashboardViewModel(itemProvider: POSItemProviderPreview(), - cardPresentPaymentService: CardPresentPaymentPreviewService(), - orderService: POSOrderPreviewService())) + ItemListView(viewModel: ItemSelectorViewModel(itemProvider: POSItemProviderPreview())) } #endif diff --git a/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift b/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift index 5259ef5cda2..23f2156d47d 100644 --- a/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift +++ b/WooCommerce/Classes/POS/Presentation/PointOfSaleDashboardView.swift @@ -14,7 +14,7 @@ struct PointOfSaleDashboardView: View { HStack { switch viewModel.orderStage { case .building: - productGridView + productListView Spacer() if viewModel.isCartCollapsed { collapsedCartView @@ -29,9 +29,6 @@ struct PointOfSaleDashboardView: View { } .padding() } - .task { - await viewModel.populatePointOfSaleItems() - } .background(Color.posBackgroundGreyi3) .navigationBarBackButtonHidden(true) .toolbar { @@ -76,12 +73,9 @@ private extension PointOfSaleDashboardView { .cornerRadius(16) } - var productGridView: some View { - ItemListView(viewModel: viewModel) + var productListView: some View { + ItemListView(viewModel: viewModel.itemSelectorViewModel) .frame(maxWidth: .infinity) - .refreshable { - await viewModel.reload() - } } } diff --git a/WooCommerce/Classes/POS/ViewModels/ItemSelectorViewModel.swift b/WooCommerce/Classes/POS/ViewModels/ItemSelectorViewModel.swift new file mode 100644 index 00000000000..23a3911504d --- /dev/null +++ b/WooCommerce/Classes/POS/ViewModels/ItemSelectorViewModel.swift @@ -0,0 +1,48 @@ +import Combine +import SwiftUI +import protocol Yosemite.POSItem +import protocol Yosemite.POSItemProvider + +final class ItemSelectorViewModel: ObservableObject { + let selectedItemPublisher: AnyPublisher + + @Published private(set) var items: [POSItem] = [] + @Published private(set) var isSyncingItems: Bool = true + + private let itemProvider: POSItemProvider + private let selectedItemSubject: PassthroughSubject = .init() + + init(itemProvider: POSItemProvider) { + self.itemProvider = itemProvider + selectedItemPublisher = selectedItemSubject.eraseToAnyPublisher() + } + + func select(_ item: POSItem) { + selectedItemSubject.send(item) + } + + @MainActor + func populatePointOfSaleItems() async { + isSyncingItems = true + do { + items = try await itemProvider.providePointOfSaleItems() + } catch { + DDLogError("Error on load while fetching product data: \(error)") + } + isSyncingItems = false + } + + @MainActor + func reload() async { + isSyncingItems = true + do { + let newItems = try await itemProvider.providePointOfSaleItems() + // Only clears in-memory items if the `do` block continues, otherwise we keep them in memory. + items.removeAll() + items = newItems + } catch { + DDLogError("Error on reload while updating product data: \(error)") + } + isSyncingItems = false + } +} diff --git a/WooCommerce/Classes/POS/ViewModels/PointOfSaleDashboardViewModel.swift b/WooCommerce/Classes/POS/ViewModels/PointOfSaleDashboardViewModel.swift index f25ff5b3035..9520dc6372d 100644 --- a/WooCommerce/Classes/POS/ViewModels/PointOfSaleDashboardViewModel.swift +++ b/WooCommerce/Classes/POS/ViewModels/PointOfSaleDashboardViewModel.swift @@ -36,6 +36,8 @@ final class PointOfSaleDashboardViewModel: ObservableObject { } } + let itemSelectorViewModel: ItemSelectorViewModel + @Published private(set) var items: [POSItem] = [] @Published private(set) var itemsInCart: [CartItem] = [] { didSet { @@ -79,52 +81,29 @@ final class PointOfSaleDashboardViewModel: ObservableObject { /// If the merchant goes back to the product selection screen and makes changes, this should be updated when they return to the checkout. @Published private var order: POSOrder? @Published private(set) var isSyncingOrder: Bool = false - @Published private(set) var isSyncingItems: Bool = true private let orderService: POSOrderServiceProtocol - private let itemProvider: POSItemProvider private let cardPresentPaymentService: CardPresentPaymentFacade private let currencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings) + private var cancellables: Set = [] + init(itemProvider: POSItemProvider, cardPresentPaymentService: CardPresentPaymentFacade, orderService: POSOrderServiceProtocol) { - self.itemProvider = itemProvider self.cardPresentPaymentService = cardPresentPaymentService self.cardReaderConnectionViewModel = CardReaderConnectionViewModel(cardPresentPayment: cardPresentPaymentService) self.orderService = orderService + self.itemSelectorViewModel = .init(itemProvider: itemProvider) + + observeSelectedItemToAddToCart() observeCardPresentPaymentEvents() observeItemsInCartForCartTotal() observePaymentStateForButtonDisabledProperties() } - @MainActor - func populatePointOfSaleItems() async { - isSyncingItems = true - do { - items = try await itemProvider.providePointOfSaleItems() - } catch { - DDLogError("Error on load while fetching product data: \(error)") - } - isSyncingItems = false - } - - @MainActor - func reload() async { - isSyncingItems = true - do { - let newItems = try await itemProvider.providePointOfSaleItems() - // Only clears in-memory items if the `do` block continues, otherwise we keep them in memory. - items.removeAll() - items = newItems - } catch { - DDLogError("Error on reload while updating product data: \(error)") - } - isSyncingItems = false - } - var canDeleteItemsFromCart: Bool { return orderStage != .finalizing } @@ -253,6 +232,15 @@ final class PointOfSaleDashboardViewModel: ObservableObject { } } +private extension PointOfSaleDashboardViewModel { + func observeSelectedItemToAddToCart() { + itemSelectorViewModel.selectedItemPublisher.sink { [weak self] selectedItem in + self?.addItemToCart(selectedItem) + } + .store(in: &cancellables) + } +} + private extension PointOfSaleDashboardViewModel { func observeItemsInCartForCartTotal() { $itemsInCart diff --git a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj index 593bdaa9c21..01853ba9a0e 100644 --- a/WooCommerce/WooCommerce.xcodeproj/project.pbxproj +++ b/WooCommerce/WooCommerce.xcodeproj/project.pbxproj @@ -1468,6 +1468,8 @@ 6832C7CA26DA5C4500BA4088 /* LabeledTextViewTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6832C7C926DA5C4500BA4088 /* LabeledTextViewTableViewCell.swift */; }; 6832C7CC26DA5FDF00BA4088 /* LabeledTextViewTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 6832C7CB26DA5FDE00BA4088 /* LabeledTextViewTableViewCell.xib */; }; 683421642ACE9391009021D7 /* ProductDiscountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683421632ACE9391009021D7 /* ProductDiscountView.swift */; }; + 683763162C2E44B800AD51D0 /* ItemSelectorViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683763152C2E44B800AD51D0 /* ItemSelectorViewModel.swift */; }; + 683763182C2E497000AD51D0 /* ItemSelectorViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683763172C2E497000AD51D0 /* ItemSelectorViewModelTests.swift */; }; 683AA9D62A303CB70099F7BA /* UpgradesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 683AA9D52A303CB70099F7BA /* UpgradesViewModelTests.swift */; }; 684AB83A2870677F003DFDD1 /* CardReaderManualsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684AB8392870677F003DFDD1 /* CardReaderManualsView.swift */; }; 684AB83C2873DF04003DFDD1 /* CardReaderManualsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684AB83B2873DF04003DFDD1 /* CardReaderManualsViewModel.swift */; }; @@ -4389,6 +4391,8 @@ 6832C7C926DA5C4500BA4088 /* LabeledTextViewTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabeledTextViewTableViewCell.swift; sourceTree = ""; }; 6832C7CB26DA5FDE00BA4088 /* LabeledTextViewTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LabeledTextViewTableViewCell.xib; sourceTree = ""; }; 683421632ACE9391009021D7 /* ProductDiscountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDiscountView.swift; sourceTree = ""; }; + 683763152C2E44B800AD51D0 /* ItemSelectorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectorViewModel.swift; sourceTree = ""; }; + 683763172C2E497000AD51D0 /* ItemSelectorViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectorViewModelTests.swift; sourceTree = ""; }; 683AA9D52A303CB70099F7BA /* UpgradesViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesViewModelTests.swift; sourceTree = ""; }; 684AB8392870677F003DFDD1 /* CardReaderManualsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualsView.swift; sourceTree = ""; }; 684AB83B2873DF04003DFDD1 /* CardReaderManualsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualsViewModel.swift; sourceTree = ""; }; @@ -6619,6 +6623,7 @@ isa = PBXGroup; children = ( 026826922BF59D830036F959 /* PointOfSaleDashboardViewModel.swift */, + 683763152C2E44B800AD51D0 /* ItemSelectorViewModel.swift */, ); path = ViewModels; sourceTree = ""; @@ -12131,6 +12136,7 @@ isa = PBXGroup; children = ( DABF35262C11B426006AF826 /* PointOfSaleDashboardViewModelTests.swift */, + 683763172C2E497000AD51D0 /* ItemSelectorViewModelTests.swift */, ); path = ViewModels; sourceTree = ""; @@ -14708,6 +14714,7 @@ 02BAB02724D13A6400F8B06E /* ProductVariationFormActionsFactory.swift in Sources */, 45CDAFAE2434CFCA00F83C22 /* ProductCatalogVisibilityViewController.swift in Sources */, D85B8333222FABD1002168F3 /* StatusListTableViewCell.swift in Sources */, + 683763162C2E44B800AD51D0 /* ItemSelectorViewModel.swift in Sources */, DE2E8EB729547771002E4B14 /* ApplicationPasswordDisabledViewModel.swift in Sources */, 0259D65D2582248D003B1CD6 /* PrintShippingLabelViewController.swift in Sources */, D881A31B256B5CC500FE5605 /* ULErrorViewController.swift in Sources */, @@ -16271,6 +16278,7 @@ FE3E427726A8545B00C596CE /* MockRoleEligibilityUseCase.swift in Sources */, 02F67FF525806E0100C3BAD2 /* ShippingLabelTrackingURLGeneratorTests.swift in Sources */, CE29FEF62C009F5F007679C2 /* ShippingLineRowViewModelTests.swift in Sources */, + 683763182C2E497000AD51D0 /* ItemSelectorViewModelTests.swift in Sources */, 20CCBF212B0E15C0003102E6 /* WooPaymentsDepositsCurrencyOverviewViewModelTests.swift in Sources */, 2602A64227BD89CE00B347F1 /* NewOrderInitialStatusResolverTests.swift in Sources */, 0235354E2999D17A00BF77D3 /* DomainSettingsViewModelTests.swift in Sources */, diff --git a/WooCommerce/WooCommerceTests/POS/ViewModels/ItemSelectorViewModelTests.swift b/WooCommerce/WooCommerceTests/POS/ViewModels/ItemSelectorViewModelTests.swift new file mode 100644 index 00000000000..c4df12284c6 --- /dev/null +++ b/WooCommerce/WooCommerceTests/POS/ViewModels/ItemSelectorViewModelTests.swift @@ -0,0 +1,87 @@ +import XCTest +import Combine +@testable import WooCommerce +@testable import protocol Yosemite.POSItemProvider +@testable import protocol Yosemite.POSItem +@testable import struct Yosemite.POSProduct + +final class ItemSelectorViewModelTests: XCTestCase { + private var itemProvider: POSItemProvider! + private var itemSelector: ItemSelectorViewModel! + + private var cancellables: Set = [] + + override func setUp() { + super.setUp() + itemProvider = MockPOSItemProvider() + itemSelector = ItemSelectorViewModel(itemProvider: itemProvider) + } + + override func tearDown() { + itemProvider = nil + itemSelector = nil + super.tearDown() + } + + func test_isSyncingItems_is_true_when_populatePointOfSaleItems_is_invoked_then_switches_to_false_when_completed() async { + XCTAssertEqual(itemSelector.isSyncingItems, true, "Precondition") + + // Given/When + await itemSelector.populatePointOfSaleItems() + + // Then + XCTAssertEqual(itemSelector.isSyncingItems, false) + } + + func test_isSyncingItems_is_true_when_reload_is_invoked_then_switches_to_false_when_completed() async { + XCTAssertEqual(itemSelector.isSyncingItems, true, "Precondition") + + // Given/When + await itemSelector.reload() + + // Then + XCTAssertEqual(itemSelector.isSyncingItems, false) + } + + func test_itemSelector_when_select_item_then_sends_item_to_publisher() { + // Given + let item = Self.makeItem() + let expectation = XCTestExpectation(description: "Publisher should emit the selected item") + + var receivedItem: POSItem? + itemSelector.selectedItemPublisher.sink { item in + receivedItem = item + expectation.fulfill() + } + .store(in: &cancellables) + + // When + itemSelector.select(item) + + // Then + XCTAssertEqual(receivedItem?.productID, item.productID) + } + +} + +private extension ItemSelectorViewModelTests { + final class MockPOSItemProvider: POSItemProvider { + var items: [POSItem] = [] + + func providePointOfSaleItems() async throws -> [Yosemite.POSItem] { + let item = makeItem() + return [item] + } + } + + static func makeItem() -> POSItem { + return POSProduct(itemID: UUID(), + productID: 0, + name: "", + price: "", + formattedPrice: "", + itemCategories: [], + productImageSource: nil, + productType: .simple) + } +} diff --git a/WooCommerce/WooCommerceTests/POS/ViewModels/PointOfSaleDashboardViewModelTests.swift b/WooCommerce/WooCommerceTests/POS/ViewModels/PointOfSaleDashboardViewModelTests.swift index 243340fd6ba..1bf3f2df09b 100644 --- a/WooCommerce/WooCommerceTests/POS/ViewModels/PointOfSaleDashboardViewModelTests.swift +++ b/WooCommerce/WooCommerceTests/POS/ViewModels/PointOfSaleDashboardViewModelTests.swift @@ -59,52 +59,6 @@ final class PointOfSaleDashboardViewModelTests: XCTestCase { XCTAssertFalse(sut.isCartCollapsed) } - func test_isSyncingItems_is_true_when_populatePointOfSaleItems_is_invoked_then_switches_to_false_when_completed() async { - XCTAssertEqual(sut.isSyncingItems, true, "Precondition") - - // Given/When - await sut.populatePointOfSaleItems() - - // Then - XCTAssertEqual(sut.isSyncingItems, false) - } - - func test_isSyncingItems_is_true_when_reload_is_invoked_then_toggled_to_false_when_completed() async throws { - XCTAssertEqual(sut.isSyncingItems, true, "Precondition") - - // Given/When - await sut.reload() - - // Then - XCTAssertEqual(sut.isSyncingItems, false) - } - - func test_isSyncingItems_is_true_when_reload_is_invoked_then_toggled_to_false_when_error() async throws { - // Given - let itemProvider = MockPOSItemProvider() - itemProvider.shouldThrowError = true - - let sut = PointOfSaleDashboardViewModel(itemProvider: itemProvider, - cardPresentPaymentService: cardPresentPaymentService, - orderService: orderService) - XCTAssertEqual(sut.isSyncingItems, true, "Precondition") - - // Given/When - await sut.reload() - - // Then - XCTAssertEqual(sut.isSyncingItems, false) - } - - func test_reload_invokes_providePointOfSaleItems() async { - // Given/When - XCTAssertEqual(itemProvider.provideItemsInvocationCount, 0) - await sut.reload() - - // Then - XCTAssertEqual(itemProvider.provideItemsInvocationCount, 1) - } - func test_removeAllItemsFromCart_removes_all_items_from_cart() { // Given let numberOfItems = Int.random(in: 1...5) @@ -130,27 +84,11 @@ final class PointOfSaleDashboardViewModelTests: XCTestCase { } private extension PointOfSaleDashboardViewModelTests { - enum POSError: Error { - case forcedError - } - final class MockPOSItemProvider: POSItemProvider { var items: [POSItem] = [] - var shouldThrowError: Bool = false - var provideItemsInvocationCount = 0 func providePointOfSaleItems() async throws -> [Yosemite.POSItem] { - provideItemsInvocationCount += 1 - if shouldThrowError { - throw POSError.forcedError - } - return items - } - - func simulate(items: [POSItem]) { - for item in items { - self.items.append(item) - } + [] } } }