Skip to content

[Woo POS] Initial mechanism to handle quantities and out of stock #12767

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

Merged
merged 11 commits into from
May 22, 2024
Merged
6 changes: 5 additions & 1 deletion WooCommerce/Classes/POS/Models/POSProduct.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ public struct POSProduct {
public let productID: Int64
public let name: String
public let price: String
// The WooCommerce core API for Product makes stockQuantity Int or null, however some extensions allow decimal values as well.
// We might want to use Decimal type for consistency with the rest of the app
public let stockQuantity: Int

public init(itemID: UUID, productID: Int64, name: String, price: String) {
public init(itemID: UUID, productID: Int64, name: String, price: String, stockQuantity: Int) {
self.itemID = itemID
self.productID = productID
self.name = name
self.price = price
self.stockQuantity = stockQuantity
}
}
12 changes: 7 additions & 5 deletions WooCommerce/Classes/POS/Presentation/CartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,14 @@ struct CartView: View {
.padding(.vertical, 8)
.font(.title)
.foregroundColor(Color.white)
ForEach(viewModel.productsInCart, id: \.product.productID) { cartProduct in
ProductRowView(cartProduct: cartProduct) {
viewModel.removeProductFromCart(cartProduct)
ScrollView {
ForEach(viewModel.productsInCart, id: \.product.productID) { cartProduct in
ProductRowView(cartProduct: cartProduct) {
viewModel.removeProductFromCart(cartProduct)
}
.background(Color.tertiaryBackground)
.padding(.horizontal, 32)
}
.background(Color.tertiaryBackground)
.padding(.horizontal, 32)
}
Spacer()
checkoutButton
Expand Down
28 changes: 28 additions & 0 deletions WooCommerce/Classes/POS/Presentation/FilterView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import SwiftUI

struct FilterView: View {
@ObservedObject private var viewModel: PointOfSaleDashboardViewModel

init(viewModel: PointOfSaleDashboardViewModel) {
self.viewModel = viewModel
}

var body: some View {
Button("Filter") {
// TODO: https://github.com/woocommerce/woocommerce-ios/issues/12761
}
.frame(maxWidth: .infinity, idealHeight: 120)
.font(.title2)
.foregroundColor(Color.white)
.background(Color.secondaryBackground)
.cornerRadius(10)
.border(Color.white, width: 2)
}
}

#if DEBUG
#Preview {
FilterView(viewModel: PointOfSaleDashboardViewModel(products: [],
cardReaderConnectionViewModel: .init(state: .connectingToReader)))
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,9 @@ struct PointOfSaleDashboardView: View {
}
}

#if DEBUG
#Preview {
PointOfSaleDashboardView(viewModel: PointOfSaleDashboardViewModel(products: POSProductFactory.makeFakeProducts(),
cardReaderConnectionViewModel: .init(state: .connectingToReader)))
}
#endif
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import SwiftUI

public struct PointOfSaleEntryPointView: View {
struct PointOfSaleEntryPointView: View {
// TODO:
// Temporary. DI proper product models once we have a data layer
private let viewModel: PointOfSaleDashboardViewModel = {
Expand All @@ -19,7 +19,7 @@ public struct PointOfSaleEntryPointView: View {
self.hideAppTabBar = hideAppTabBar
}

public var body: some View {
var body: some View {
PointOfSaleDashboardView(viewModel: viewModel)
.onAppear {
hideAppTabBar(true)
Expand All @@ -30,6 +30,8 @@ public struct PointOfSaleEntryPointView: View {
}
}

#if DEBUG
#Preview {
PointOfSaleEntryPointView(hideAppTabBar: { _ in })
}
#endif
16 changes: 12 additions & 4 deletions WooCommerce/Classes/POS/Presentation/ProductCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,21 @@ struct ProductCardView: View {
self.onProductCardTapped = onProductCardTapped
}

var outOfStock: Bool {
// TODO: Handle out of stock
// wp.me/p91TBi-bcW#comment-12123
product.stockQuantity <= 0
}

var body: some View {
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe make the whole view a button so that tapping the entire view content adds the product to cart?

var body: some View {
        Button(action: {
            onProductCardTapped?()
        }, label: {
            VStack {
                Text(product.name)
                    .foregroundStyle(Color.primaryBackground)
                Text(outOfStock ? "Out of Stock" : product.price)
                    .foregroundStyle(Color.primaryBackground)
                HStack(spacing: 8) {
                    QuantityBadgeView(product.stockQuantity)
                        .frame(width: 50, height: 50)
                    Spacer()
                    Button(action: {
                        onProductCardTapped?()
                    }, label: { })
                    .buttonStyle(POSPlusButtonStyle())
                    .frame(width: 56, height: 56)
                }
            }
            .frame(maxWidth: .infinity)
            .background(outOfStock ? Color.pink : Color.tertiaryBackground)
        })
        .buttonStyle(.plain)
    }

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'll leave this change out for the moment, since we have a specific button for the + and we do not know if tapping in the card itself should have a different behaviour (if any)

VStack {
Text(product.name)
.foregroundStyle(Color.primaryBackground)
Text(product.price)
Text(outOfStock ? "Out of Stock" : product.price)
.foregroundStyle(Color.primaryBackground)
HStack(spacing: 8) {
QuantityBadgeView()
.frame(width: 56, height: 56)
QuantityBadgeView(product.stockQuantity)
.frame(width: 50, height: 50)
Spacer()
Button(action: {
onProductCardTapped?()
Expand All @@ -27,10 +33,12 @@ struct ProductCardView: View {
}
}
.frame(maxWidth: .infinity)
.background(Color.tertiaryBackground)
.background(outOfStock ? Color.pink : Color.tertiaryBackground)
}
}

#if DEBUG
#Preview {
ProductCardView(product: POSProductFactory.makeProduct())
}
#endif
32 changes: 24 additions & 8 deletions WooCommerce/Classes/POS/Presentation/ProductGridView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,41 @@ struct ProductGridView: View {
}

var body: some View {
let columns: [GridItem] = Array(repeating: .init(.flexible()),
let columns: [GridItem] = Array(repeating: .init(.fixed(120)),
count: viewModel.products.count)

ScrollView {
LazyVGrid(columns: columns, spacing: 8) {
ForEach(viewModel.products, id: \.productID) { product in
ProductCardView(product: product) {
viewModel.addProductToCart(product)
VStack {
Text("Product List")
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
.font(.title)
.foregroundColor(Color.white)
HStack {
SearchView()
Spacer()
FilterView(viewModel: viewModel)
}
.padding(.vertical, 0)
ScrollView {
LazyVGrid(columns: columns, spacing: 16) {
ForEach(viewModel.products, id: \.productID) { product in
ProductCardView(product: product) {
viewModel.addProductToCart(product)
}
.foregroundColor(Color.primaryText)
.background(Color.secondaryBackground)
}
.foregroundColor(Color.primaryText)
.background(Color.secondaryBackground)
}
}
}
.padding(.horizontal, 32)
.background(Color.secondaryBackground)
}
}

#if DEBUG
#Preview {
ProductGridView(viewModel: PointOfSaleDashboardViewModel(products: POSProductFactory.makeFakeProducts(),
cardReaderConnectionViewModel: .init(state: .connectingToReader)))
}
#endif
12 changes: 10 additions & 2 deletions WooCommerce/Classes/POS/Presentation/QuantityBadgeView.swift
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import SwiftUI

struct QuantityBadgeView: View {
private let productQuantity: Int

init(_ productQuantity: Int) {
self.productQuantity = productQuantity
}

var body: some View {
Text("n")
Text("\(productQuantity)")
.foregroundColor(Color.white)
}
}

#if DEBUG
#Preview {
QuantityBadgeView()
QuantityBadgeView(3)
}
#endif
20 changes: 20 additions & 0 deletions WooCommerce/Classes/POS/Presentation/SearchView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import SwiftUI

struct SearchView: View {
// TODO: https://github.com/woocommerce/woocommerce-ios/issues/12762
var body: some View {
TextField("Search", text: .constant("Search"))
.frame(maxWidth: .infinity, idealHeight: 120)
.font(.title2)
.foregroundColor(Color.white)
.background(Color.secondaryBackground)
.cornerRadius(10)
.border(Color.white, width: 2)
}
}

#if DEBUG
#Preview {
SearchView()
}
#endif
14 changes: 9 additions & 5 deletions WooCommerce/Classes/POS/Utils/POSProductFactory.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,19 @@ import Foundation
///
final class POSProductFactory {
static func makeProduct() -> POSProduct {
POSProduct(itemID: UUID(), productID: 1, name: "Product 1", price: "$1.00")
POSProduct(itemID: UUID(),
productID: 1,
name: "Product 1",
price: "$1.00",
stockQuantity: 10)
}

static func makeFakeProducts() -> [POSProduct] {
return [
POSProduct(itemID: UUID(), productID: 1, name: "Product 1", price: "$1.00"),
POSProduct(itemID: UUID(), productID: 2, name: "Product 2", price: "$2.00"),
POSProduct(itemID: UUID(), productID: 3, name: "Product 3", price: "$3.00"),
POSProduct(itemID: UUID(), productID: 4, name: "Product 4", price: "$4.00"),
POSProduct(itemID: UUID(), productID: 1, name: "Product 1", price: "$1.00", stockQuantity: 10),
POSProduct(itemID: UUID(), productID: 2, name: "Product 2", price: "$2.00", stockQuantity: 10),
POSProduct(itemID: UUID(), productID: 3, name: "Product 3", price: "$3.00", stockQuantity: 10),
POSProduct(itemID: UUID(), productID: 4, name: "Product 4", price: "$4.00", stockQuantity: 0),
]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,54 @@ final class PointOfSaleDashboardViewModel: ObservableObject {
self.cardReaderConnectionViewModel = cardReaderConnectionViewModel
}

// Creates a unique `CartProduct` from a `Product`, and adds it to the Cart
func addProductToCart(_ product: POSProduct) {
let cartProduct = CartProduct(id: UUID(), product: product, quantity: 1)
productsInCart.append(cartProduct)
if product.stockQuantity > 0 {
reduceInventory(product)

let cartProduct = CartProduct(id: UUID(), product: product, quantity: 1)
productsInCart.append(cartProduct)
} else {
// TODO: Handle out of stock
// wp.me/p91TBi-bcW#comment-12123
return
}
}

func reduceInventory(_ product: POSProduct) {
guard let index = products.firstIndex(where: { $0.itemID == product.itemID }) else {
return
}
let updatedQuantity = product.stockQuantity - 1
let updatedProduct = POSProduct(itemID: product.itemID,
productID: product.productID,
name: product.name,
price: product.price,
stockQuantity: updatedQuantity)
products[index] = updatedProduct
}

func restoreInventory(_ product: POSProduct) {
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This could simplified into an unique method for reducing/increasing, no strong reason to keep them separate at this point, just preference.

guard let index = products.firstIndex(where: { $0.itemID == product.itemID }) else {
return
}
let updatedQuantity = product.stockQuantity + 1
let updatedProduct = POSProduct(itemID: product.itemID,
productID: product.productID,
name: product.name,
price: product.price,
stockQuantity: updatedQuantity)
products[index] = updatedProduct
}

// Removes a `CartProduct` from the Cart
func removeProductFromCart(_ cartProduct: CartProduct) {
productsInCart.removeAll(where: { $0.id == cartProduct.id })

// When removing an item from the cart, restore previous inventory
guard let match = products.first(where: { $0.productID == cartProduct.product.productID }) else {
return
}
restoreInventory(match)
}

func submitCart() {
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 @@ -1366,6 +1366,8 @@
68A905012ACCFC13004C71D3 /* CollapsibleProductCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68A905002ACCFC13004C71D3 /* CollapsibleProductCard.swift */; };
68AC9D292ACE598B0042F784 /* ProductImageThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68AC9D282ACE598B0042F784 /* ProductImageThumbnail.swift */; };
68B6F22B2ADE7ED500D171FC /* TooltipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68B6F22A2ADE7ED500D171FC /* TooltipView.swift */; };
68BB75332BFAF0FD0031C6FE /* FilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68BB75322BFAF0FD0031C6FE /* FilterView.swift */; };
68BB75352BFAF12F0031C6FE /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68BB75342BFAF12F0031C6FE /* SearchView.swift */; };
68C31B712A8617C500AE5C5A /* NewNoteViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68C31B702A8617C500AE5C5A /* NewNoteViewModel.swift */; };
68D1BEDB28FFEDC20074A29E /* OrderCustomerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68D1BEDA28FFEDC20074A29E /* OrderCustomerListView.swift */; };
68D1BEDD2900E4180074A29E /* CustomerSearchUICommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 68D1BEDC2900E4180074A29E /* CustomerSearchUICommand.swift */; };
Expand Down Expand Up @@ -4111,6 +4113,8 @@
68A905002ACCFC13004C71D3 /* CollapsibleProductCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleProductCard.swift; sourceTree = "<group>"; };
68AC9D282ACE598B0042F784 /* ProductImageThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductImageThumbnail.swift; sourceTree = "<group>"; };
68B6F22A2ADE7ED500D171FC /* TooltipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TooltipView.swift; sourceTree = "<group>"; };
68BB75322BFAF0FD0031C6FE /* FilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterView.swift; sourceTree = "<group>"; };
68BB75342BFAF12F0031C6FE /* SearchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchView.swift; sourceTree = "<group>"; };
68C31B702A8617C500AE5C5A /* NewNoteViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewNoteViewModel.swift; sourceTree = "<group>"; };
68D1BEDA28FFEDC20074A29E /* OrderCustomerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OrderCustomerListView.swift; sourceTree = "<group>"; };
68D1BEDC2900E4180074A29E /* CustomerSearchUICommand.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomerSearchUICommand.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -6296,6 +6300,8 @@
026826A22BF59DF60036F959 /* ProductRowView.swift */,
026826A62BF59DF70036F959 /* QuantityBadgeView.swift */,
026826A42BF59DF60036F959 /* ProductCardView.swift */,
68BB75322BFAF0FD0031C6FE /* FilterView.swift */,
68BB75342BFAF12F0031C6FE /* SearchView.swift */,
);
path = Presentation;
sourceTree = "<group>";
Expand Down Expand Up @@ -14131,6 +14137,7 @@
0262DA5B23A244830029AF30 /* Product+ShippingSettingsViewModels.swift in Sources */,
02D29A8E29F7C26000473D6D /* InputAccessoryView.swift in Sources */,
20CC1EDB2AFA8381006BD429 /* InPersonPaymentsMenu.swift in Sources */,
68BB75352BFAF12F0031C6FE /* SearchView.swift in Sources */,
0201E42D2946C23600C793C7 /* OptionalStoreCreationProfilerQuestionView.swift in Sources */,
0216272B2379662C000208D2 /* DefaultProductFormTableViewModel.swift in Sources */,
45E9A6E424DAE1EA00A600E8 /* ProductReviewsViewController.swift in Sources */,
Expand Down Expand Up @@ -14161,6 +14168,7 @@
B9E4364C287587D300883CFA /* FeatureAnnouncementCardView.swift in Sources */,
267F60132A0C24D700CD1E4E /* PrivacyBannerViewController.swift in Sources */,
D8610BD2256F291000A5DF27 /* JetpackErrorViewModel.swift in Sources */,
68BB75332BFAF0FD0031C6FE /* FilterView.swift in Sources */,
26C6E8E626E6B5F500C7BB0F /* StateSelectorViewModel.swift in Sources */,
B99B30CD2A85200D0066743D /* AddressFormViewModel.swift in Sources */,
020B2F9423BDDBDC00BD79AD /* ProductUpdateError+UI.swift in Sources */,
Expand Down