Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Woo POS] Extract cart logic to CartViewModel #13191

Merged
merged 17 commits into from
Jul 1, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 26 additions & 13 deletions WooCommerce/Classes/POS/Presentation/CartView.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import SwiftUI
import protocol Yosemite.POSItem

struct CartView: View {
@ObservedObject private var viewModel: PointOfSaleDashboardViewModel
@ObservedObject private var cartViewModel: CartViewModel

init(viewModel: PointOfSaleDashboardViewModel) {
init(viewModel: PointOfSaleDashboardViewModel, cartViewModel: CartViewModel) {
self.viewModel = viewModel
self.cartViewModel = cartViewModel
}

var body: some View {
Expand All @@ -13,16 +16,17 @@ struct CartView: View {
Text("Cart")
.foregroundColor(Color.posPrimaryTexti3)
Spacer()
if let temsInCartLabel = viewModel.itemsInCartLabel {
if let temsInCartLabel = cartViewModel.itemsInCartLabel {
Text(temsInCartLabel)
.foregroundColor(Color.posPrimaryTexti3)
Button {
viewModel.removeAllItemsFromCart()
cartViewModel.removeAllItemsFromCart()
} label: {
Text("Clear all")
.foregroundColor(Color.init(uiColor: .wooCommercePurple(.shade60)))
}
.padding(.horizontal, 8)
.renderedIf(cartViewModel.canDeleteItemsFromCart)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
Expand All @@ -32,19 +36,19 @@ struct CartView: View {
.foregroundColor(Color.white)
ScrollViewReader { proxy in
ScrollView {
ForEach(viewModel.itemsInCart, id: \.id) { cartItem in
ForEach(cartViewModel.itemsInCart, id: \.id) { cartItem in
ItemRowView(cartItem: cartItem,
onItemRemoveTapped: viewModel.canDeleteItemsFromCart ? {
viewModel.removeItemFromCart(cartItem)
onItemRemoveTapped: cartViewModel.canDeleteItemsFromCart ? {
cartViewModel.removeItemFromCart(cartItem)
} : nil)
.id(cartItem.id)
.background(Color.posBackgroundGreyi3)
.padding(.horizontal, 32)
}
}
.onChange(of: viewModel.itemToScrollToWhenCartUpdated?.id) { _ in
.onChange(of: cartViewModel.itemToScrollToWhenCartUpdated?.id) { _ in
if viewModel.orderStage == .building,
let last = viewModel.itemToScrollToWhenCartUpdated?.id {
let last = cartViewModel.itemToScrollToWhenCartUpdated?.id {
withAnimation {
proxy.scrollTo(last)
}
Expand Down Expand Up @@ -72,7 +76,7 @@ struct CartView: View {
private extension CartView {
var checkoutButton: some View {
Button {
viewModel.submitCart()
cartViewModel.submitCart()
} label: {
HStack {
Spacer()
Expand All @@ -88,7 +92,7 @@ private extension CartView {

var addMoreButton: some View {
Button {
viewModel.addMoreToCart()
cartViewModel.addMoreToCart()
} label: {
Spacer()
Text("Add More")
Expand All @@ -102,9 +106,18 @@ private extension CartView {
}

#if DEBUG
import Combine
#Preview {
CartView(viewModel: PointOfSaleDashboardViewModel(itemProvider: POSItemProviderPreview(),
cardPresentPaymentService: CardPresentPaymentPreviewService(),
orderService: POSOrderPreviewService()))
// TODO:
// Simplify this by mocking `CartViewModel`
// https://github.com/woocommerce/woocommerce-ios/issues/13207
let orderStageSubject = PassthroughSubject<PointOfSaleDashboardViewModel.OrderStage, Never>()
let orderStagePublisher = orderStageSubject.eraseToAnyPublisher()
Comment on lines +114 to +115
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In SwiftUI preview, we could also pass a constant value like passing Just< PointOfSaleDashboardViewModel.OrderStage >(.building) to CartViewModel(orderStage:). We can potentially have two previews, one for each order stage case.

Would like to see what you think about this change, I will merge the PR without updating this 🙂

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for merging, appreciated! 🙇

In SwiftUI preview, we could also pass a constant value like passing Just< PointOfSaleDashboardViewModel.OrderStage >(.building) to CartViewModel(orderStage:). We can potentially have two previews, one for each order stage case.

That sounds good, I've updated #13207 for reference

let dashboardViewModel = PointOfSaleDashboardViewModel(itemProvider: POSItemProviderPreview(),
cardPresentPaymentService: CardPresentPaymentPreviewService(),
orderService: POSOrderPreviewService())
let cartViewModel = CartViewModel(orderStage: orderStagePublisher)

return CartView(viewModel: dashboardViewModel, cartViewModel: cartViewModel)
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,9 @@ private extension PointOfSaleDashboardView {
}

var cartView: some View {
CartView(viewModel: viewModel)
.frame(maxWidth: .infinity)
CartView(viewModel: viewModel,
cartViewModel: viewModel.cartViewModel)
.frame(maxWidth: .infinity)
}

var totalsView: some View {
Expand Down
64 changes: 64 additions & 0 deletions WooCommerce/Classes/POS/ViewModels/CartViewModel.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import SwiftUI
import Combine
import protocol Yosemite.POSItem

final class CartViewModel: ObservableObject {
/// Emits cart items when the CTA is tapped to submit the cart.
let cartSubmissionPublisher: AnyPublisher<[CartItem], Never>
private let cartSubmissionSubject: PassthroughSubject<[CartItem], Never> = .init()

/// Emits a signal when the CTA is tapped to update the cart.
let addMoreToCartActionPublisher: AnyPublisher<Void, Never>
private let addMoreToCartActionSubject: PassthroughSubject<Void, Never> = .init()

@Published private(set) var itemsInCart: [CartItem] = []

// It should be synced with the source of truth in `PointOfSaleDashboardViewModel`.
@Published private var orderStage: PointOfSaleDashboardViewModel.OrderStage = .building

var canDeleteItemsFromCart: Bool {
orderStage != .finalizing
}

init(orderStage: AnyPublisher<PointOfSaleDashboardViewModel.OrderStage, Never>) {
cartSubmissionPublisher = cartSubmissionSubject.eraseToAnyPublisher()
addMoreToCartActionPublisher = addMoreToCartActionSubject.eraseToAnyPublisher()
orderStage.assign(to: &$orderStage)
}

func addItemToCart(_ item: POSItem) {
let cartItem = CartItem(id: UUID(), item: item, quantity: 1)
itemsInCart.append(cartItem)
}

func removeItemFromCart(_ cartItem: CartItem) {
itemsInCart.removeAll(where: { $0.id == cartItem.id })
}

func removeAllItemsFromCart() {
itemsInCart.removeAll()
}

var itemToScrollToWhenCartUpdated: CartItem? {
return itemsInCart.last
}

var itemsInCartLabel: String? {
switch itemsInCart.count {
case 0:
return nil
default:
return String.pluralize(itemsInCart.count,
singular: "%1$d item",
plural: "%1$d items")
}
}

func submitCart() {
cartSubmissionSubject.send(itemsInCart)
}

func addMoreToCart() {
addMoreToCartActionSubject.send(())
}
}
122 changes: 55 additions & 67 deletions WooCommerce/Classes/POS/ViewModels/PointOfSaleDashboardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,12 +37,9 @@ final class PointOfSaleDashboardViewModel: ObservableObject {
}

let itemSelectorViewModel: ItemSelectorViewModel
private(set) lazy var cartViewModel: CartViewModel = CartViewModel(orderStage: $orderStage.eraseToAnyPublisher())

@Published private(set) var itemsInCart: [CartItem] = [] {
didSet {
checkIfCartEmpty()
}
}
@Published private(set) var isCartCollapsed: Bool = true

// Total amounts
@Published private(set) var formattedCartTotalPrice: String?
Expand Down Expand Up @@ -72,10 +69,16 @@ final class PointOfSaleDashboardViewModel: ObservableObject {
}

@Published private(set) var orderStage: OrderStage = .building
@Published private(set) var paymentState: PointOfSaleDashboardViewModel.PaymentState = .acceptingCard

@Published private(set) var isAddMoreDisabled: Bool = false
@Published var isExitPOSDisabled: Bool = false

private var cancellables: Set<AnyCancellable> = []

// TODO: 12998 - move the following properties to totals view model
@Published private(set) var paymentState: PointOfSaleDashboardViewModel.PaymentState = .acceptingCard
private var itemsInCart: [CartItem] = []

/// Order created the first time the checkout is shown for a given transaction.
/// 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?
Expand All @@ -86,8 +89,6 @@ final class PointOfSaleDashboardViewModel: ObservableObject {

private let currencyFormatter = CurrencyFormatter(currencySettings: ServiceLocator.currencySettings)

private var cancellables: Set<AnyCancellable> = []

init(itemProvider: POSItemProvider,
cardPresentPaymentService: CardPresentPaymentFacade,
orderService: POSOrderServiceProtocol) {
Expand All @@ -98,64 +99,15 @@ final class PointOfSaleDashboardViewModel: ObservableObject {
self.itemSelectorViewModel = .init(itemProvider: itemProvider)

observeSelectedItemToAddToCart()
observeCartItemsForCollapsedState()
observeCartSubmission()
observeCartAddMoreAction()
observeCartItemsToCheckIfCartIsEmpty()
observeCardPresentPaymentEvents()
observeItemsInCartForCartTotal()
observePaymentStateForButtonDisabledProperties()
}

var canDeleteItemsFromCart: Bool {
return orderStage != .finalizing
}

var isCartCollapsed: Bool {
itemsInCart.isEmpty
}

var itemToScrollToWhenCartUpdated: CartItem? {
return itemsInCart.last
}

func addItemToCart(_ item: POSItem) {
let cartItem = CartItem(id: UUID(), item: item, quantity: 1)
itemsInCart.append(cartItem)
}

func removeItemFromCart(_ cartItem: CartItem) {
itemsInCart.removeAll(where: { $0.id == cartItem.id })
}

func removeAllItemsFromCart() {
itemsInCart.removeAll()
}

var itemsInCartLabel: String? {
switch itemsInCart.count {
case 0:
return nil
default:
return String.pluralize(itemsInCart.count,
singular: "%1$d item",
plural: "%1$d items")
}
}

private func checkIfCartEmpty() {
if itemsInCart.isEmpty {
orderStage = .building
}
}

func submitCart() {
// TODO: https://github.com/woocommerce/woocommerce-ios/issues/12810
orderStage = .finalizing

startSyncingOrder()
}

func addMoreToCart() {
orderStage = .building
}

var areAmountsFullyCalculated: Bool {
return isSyncingOrder == false && formattedOrderTotalTaxPrice != nil && formattedOrderTotalPrice != nil
}
Expand Down Expand Up @@ -208,18 +160,22 @@ final class PointOfSaleDashboardViewModel: ObservableObject {
}

func calculateAmountsTapped() {
startSyncingOrder()
// TODO: 12998 - move to the totals view model
startSyncingOrder(cartItems: itemsInCart)
}

private func startSyncingOrder() {
private func startSyncingOrder(cartItems: [CartItem]) {
// TODO: 12998 - move to the totals view model
// At this point, this should happen in the TotalsViewModel
Task { @MainActor in
await syncOrder(for: itemsInCart)
itemsInCart = cartItems
await syncOrder(for: cartItems)
}
}

func startNewTransaction() {
// clear cart
itemsInCart.removeAll()
cartViewModel.removeAllItemsFromCart()
orderStage = .building
paymentState = .acceptingCard
order = nil
Expand All @@ -234,15 +190,47 @@ final class PointOfSaleDashboardViewModel: ObservableObject {
private extension PointOfSaleDashboardViewModel {
func observeSelectedItemToAddToCart() {
itemSelectorViewModel.selectedItemPublisher.sink { [weak self] selectedItem in
self?.addItemToCart(selectedItem)
self?.cartViewModel.addItemToCart(selectedItem)
}
.store(in: &cancellables)
}

func observeCartItemsForCollapsedState() {
cartViewModel.$itemsInCart
.map { $0.isEmpty }
.assign(to: &$isCartCollapsed)
}

func observeCartSubmission() {
cartViewModel.cartSubmissionPublisher.sink { [weak self] cartItems in
guard let self else { return }
orderStage = .finalizing
startSyncingOrder(cartItems: cartItems)
}
.store(in: &cancellables)
}

func observeCartAddMoreAction() {
cartViewModel.addMoreToCartActionPublisher.sink { [weak self] in
guard let self else { return }
orderStage = .building
}
.store(in: &cancellables)
}

func observeCartItemsToCheckIfCartIsEmpty() {
cartViewModel.$itemsInCart
.filter { $0.isEmpty }
.sink { [weak self] _ in
self?.orderStage = .building
}
.store(in: &cancellables)
}
}

private extension PointOfSaleDashboardViewModel {
func observeItemsInCartForCartTotal() {
$itemsInCart
cartViewModel.$itemsInCart
.map { [weak self] in
guard let self else { return "-" }
let totalValue: Decimal = $0.reduce(0) { partialResult, cartItem in
Expand Down
Loading