Skip to content

Commit

Permalink
[Woo POS] Extract item selection logic to ItemSelectorViewModel (#1…
Browse files Browse the repository at this point in the history
  • Loading branch information
jaclync authored Jun 28, 2024
2 parents 1b1a5b5 + c5c12be commit 375de31
Show file tree
Hide file tree
Showing 7 changed files with 174 additions and 108 deletions.
19 changes: 11 additions & 8 deletions WooCommerce/Classes/POS/Presentation/ItemListView.swift
Original file line number Diff line number Diff line change
@@ -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
}

Expand All @@ -22,25 +23,27 @@ struct ItemListView: View {
ScrollView {
ForEach(viewModel.items, id: \.productID) { item in
Button(action: {
viewModel.addItemToCart(item)
viewModel.select(item)
}, label: {
ItemCardView(item: item)
})
}
}
}
}
.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
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ struct PointOfSaleDashboardView: View {
HStack {
switch viewModel.orderStage {
case .building:
productGridView
productListView
Spacer()
if viewModel.isCartCollapsed {
collapsedCartView
Expand All @@ -29,9 +29,6 @@ struct PointOfSaleDashboardView: View {
}
.padding()
}
.task {
await viewModel.populatePointOfSaleItems()
}
.background(Color.posBackgroundGreyi3)
.navigationBarBackButtonHidden(true)
.toolbar {
Expand Down Expand Up @@ -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()
}
}
}

Expand Down
48 changes: 48 additions & 0 deletions WooCommerce/Classes/POS/ViewModels/ItemSelectorViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import Combine
import SwiftUI
import protocol Yosemite.POSItem
import protocol Yosemite.POSItemProvider

final class ItemSelectorViewModel: ObservableObject {
let selectedItemPublisher: AnyPublisher<POSItem, Never>

@Published private(set) var items: [POSItem] = []
@Published private(set) var isSyncingItems: Bool = true

private let itemProvider: POSItemProvider
private let selectedItemSubject: PassthroughSubject<POSItem, Never> = .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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ final class PointOfSaleDashboardViewModel: ObservableObject {
}
}

let itemSelectorViewModel: ItemSelectorViewModel

@Published private(set) var items: [POSItem] = []
@Published private(set) var itemsInCart: [CartItem] = [] {
didSet {
Expand Down Expand Up @@ -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<AnyCancellable> = []

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
}
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -4389,6 +4391,8 @@
6832C7C926DA5C4500BA4088 /* LabeledTextViewTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LabeledTextViewTableViewCell.swift; sourceTree = "<group>"; };
6832C7CB26DA5FDE00BA4088 /* LabeledTextViewTableViewCell.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = LabeledTextViewTableViewCell.xib; sourceTree = "<group>"; };
683421632ACE9391009021D7 /* ProductDiscountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDiscountView.swift; sourceTree = "<group>"; };
683763152C2E44B800AD51D0 /* ItemSelectorViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectorViewModel.swift; sourceTree = "<group>"; };
683763172C2E497000AD51D0 /* ItemSelectorViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSelectorViewModelTests.swift; sourceTree = "<group>"; };
683AA9D52A303CB70099F7BA /* UpgradesViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpgradesViewModelTests.swift; sourceTree = "<group>"; };
684AB8392870677F003DFDD1 /* CardReaderManualsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualsView.swift; sourceTree = "<group>"; };
684AB83B2873DF04003DFDD1 /* CardReaderManualsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CardReaderManualsViewModel.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6619,6 +6623,7 @@
isa = PBXGroup;
children = (
026826922BF59D830036F959 /* PointOfSaleDashboardViewModel.swift */,
683763152C2E44B800AD51D0 /* ItemSelectorViewModel.swift */,
);
path = ViewModels;
sourceTree = "<group>";
Expand Down Expand Up @@ -12131,6 +12136,7 @@
isa = PBXGroup;
children = (
DABF35262C11B426006AF826 /* PointOfSaleDashboardViewModelTests.swift */,
683763172C2E497000AD51D0 /* ItemSelectorViewModelTests.swift */,
);
path = ViewModels;
sourceTree = "<group>";
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
Original file line number Diff line number Diff line change
@@ -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<AnyCancellable> = []

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)
}
}
Loading

0 comments on commit 375de31

Please sign in to comment.