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

[Do Not Merge] POS Aggregate model with multiple item types #14290

Draft
wants to merge 6 commits into
base: experiment/pos-single-source-of-truth
Choose a base branch
from
Draft
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
138 changes: 138 additions & 0 deletions WooCommerce/Classes/POS/POSDisplayableItem.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import SwiftUI
import struct Yosemite.POSProduct
import protocol Yosemite.POSItem
import enum Yosemite.ProductType

protocol POSDisplayableItem: View, Identifiable, Equatable {
var id: UUID { get }
var item: POSItem { get }
}

func createPOSDisplayableItem(for item: POSItem) -> (any POSDisplayableItem)? {
switch item {
case is POSProduct:
return POSProductItem(item: item)
case is POSDiscount:
return POSDiscountItem(item: item)
default:
return nil
}
}

struct POSProductItem: POSDisplayableItem {
var id: UUID {
product.itemID
}
var product: POSProduct
var item: POSItem { product }
@EnvironmentObject var posModel: PointOfSaleAggregateModel

init?(item: POSItem) {
guard let product = item as? POSProduct else {
return nil
}
self.product = product
}

var body: some View {
Button(action: {
let cartItem = CartItem(id: UUID(), item: product, quantity: 1)
posModel.addItemToCart(cartItem)
}, label: {
ItemCardView(item: product)
})
}

static func ==(lhs: POSProductItem, rhs: POSProductItem) -> Bool {
return lhs.product == rhs.product
}
}

struct POSDiscount: POSItem, Equatable {
var itemID = UUID()

var productID: Int64 = 0

var name: String = "A fixed discount"

var price: String = "-5.00"

var formattedPrice: String = "-$5.00"

var itemCategories: [String] = []

var productImageSource: String? = nil

var productType: Yosemite.ProductType = .simple
}

struct POSDiscountItem: POSDisplayableItem {
var id: UUID { discount.itemID }
let discount: POSDiscount
var item: POSItem { discount }

@ScaledMetric private var scale: CGFloat = 1.0

init?(item: POSItem) {
guard let discount = item as? POSDiscount else {
return nil
}
self.discount = discount
}

var body: some View {
HStack(spacing: Constants.cardSpacing) {
Rectangle()
.overlay {
Image(systemName: "basket")
}
.frame(width: min(Constants.productCardSize * scale, Constants.maximumProductCardSize),
height: Constants.productCardSize * scale)
.foregroundColor(Color(.secondarySystemFill))

DynamicHStack(spacing: Constants.textSpacing) {
Text(item.name)
.lineLimit(2)
.foregroundStyle(Color.posPrimaryText)
.multilineTextAlignment(.leading)
.font(Constants.itemNameFont)
Spacer()
Text(item.formattedPrice)
.foregroundStyle(Color.posPrimaryText)
.font(Constants.itemPriceFont)
}
.padding(.horizontal, Constants.horizontalTextPadding * (1 / scale))
.padding(.vertical, Constants.verticalTextPadding * (1 / scale))
Spacer()
}
.frame(maxWidth: .infinity, idealHeight: Constants.productCardSize * scale)
.background(Color.posSecondaryBackground)
.overlay {
RoundedRectangle(cornerRadius: Constants.productCardCornerRadius)
.stroke(Color.black, lineWidth: Constants.nilOutline)
}
.clipShape(RoundedRectangle(cornerRadius: Constants.productCardCornerRadius))
.shadow(color: Color.black.opacity(0.08), radius: 4, y: 2)
}

static func ==(lhs: POSDiscountItem, rhs: POSDiscountItem) -> Bool {
return lhs.discount == rhs.discount
}
}

private extension POSDiscountItem {
enum Constants {
static let productCardSize: CGFloat = 112
static let maximumProductCardSize: CGFloat = Constants.productCardSize * 2
static let productCardCornerRadius: CGFloat = 8
// The use of stroke means the shape is rendered as an outline (border) rather than a filled shape,
// since we still have to give it a value, we use 0 so it renders no border but it's shaped as one.
static let nilOutline: CGFloat = 0
static let cardSpacing: CGFloat = 0
static let textSpacing: CGFloat = 8
static let horizontalTextPadding: CGFloat = 32
static let verticalTextPadding: CGFloat = 8
static let itemNameFont: POSFontStyle = .posBodyEmphasized
static let itemPriceFont: POSFontStyle = .posBodyRegular
}
}
25 changes: 13 additions & 12 deletions WooCommerce/Classes/POS/PointOfSaleAggregateModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Combine
import protocol Yosemite.POSOrderServiceProtocol
import protocol Yosemite.POSItem
import protocol Yosemite.POSItemProvider
import struct Yosemite.POSProduct
import struct Yosemite.Order
import struct Yosemite.OrderItem
import struct Yosemite.POSCartItem
Expand All @@ -18,7 +19,7 @@ final class PointOfSaleAggregateModel: ObservableObject {
}

@Published private(set) var orderStage: OrderStage = .building
@Published private(set) var allItems: [POSItem] = []
private var allItems: [any POSDisplayableItem] = []
@Published private(set) var cart: [CartItem] = []
@Published private(set) var orderState: PointOfSaleOrderState = .idle
@Published private(set) var order: Order? = nil
Expand Down Expand Up @@ -70,7 +71,7 @@ final class PointOfSaleAggregateModel: ObservableObject {
@MainActor
func loadItems(pageNumber: Int) async {
do {
itemListState = .loading
itemListState = .loading(allItems)
try await fetchItems(pageNumber: pageNumber)
} catch {
itemListState = .error(PointOfSaleErrorState.errorOnLoadingProducts())
Expand All @@ -79,10 +80,15 @@ final class PointOfSaleAggregateModel: ObservableObject {

@MainActor
private func fetchItems(pageNumber: Int) async throws {
let newItems = try await itemProvider.providePointOfSaleItems(pageNumber: pageNumber)
let uniqueNewItems = newItems.filter { newItem in
!allItems.contains(where: { $0.productID == newItem.productID })
var newItems = try await itemProvider.providePointOfSaleItems(pageNumber: pageNumber)
if pageNumber == 1 {
newItems.insert(POSDiscount(), at: 0)
}
let uniqueNewItems = newItems
.filter { newItem in
!allItems.contains(where: { $0.id == newItem.itemID })
}
.compactMap(createPOSDisplayableItem(for:))

allItems.append(contentsOf: uniqueNewItems)

Expand Down Expand Up @@ -115,14 +121,9 @@ final class PointOfSaleAggregateModel: ObservableObject {
paymentState = .idle
}

func selected(item: POSItem) {
let cartItem = CartItem(id: UUID(), item: item, quantity: 1)
addItemToCart(cartItem)
analytics.track(.pointOfSaleAddItemToCart)
}

func addItemToCart(_ item: CartItem) {
cart.insert(item, at: 0)
analytics.track(.pointOfSaleAddItemToCart)
}

func removeItemFromCart(_ cartItem: CartItem) {
Expand All @@ -136,7 +137,7 @@ final class PointOfSaleAggregateModel: ObservableObject {
@MainActor
func submitCart() async {
orderStage = .finalizing
await startSyncingOrder(with: cart, allItems: allItems)
await startSyncingOrder(with: cart, allItems: allItems.map { $0.item })
}

private func startSyncingOrder(with cartItems: [CartItem], allItems: [POSItem]) async {
Expand Down
12 changes: 6 additions & 6 deletions WooCommerce/Classes/POS/PointOfSaleItemListState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,19 @@ import protocol Yosemite.POSItem
enum PointOfSaleItemListState: Equatable {
case empty
case initialLoading
case loading
case loaded([POSItem])
case loading(_ existingItems: [any POSDisplayableItem])
case loaded([any POSDisplayableItem])
case error(PointOfSaleErrorState)

// Equatable conformance for testing:
static func == (lhs: PointOfSaleItemListState, rhs: PointOfSaleItemListState) -> Bool {
switch (lhs, rhs) {
case (.initialLoading, .initialLoading),
(.empty, .empty),
(.loading, .loading):
(.empty, .empty):
return true
case (.loaded(let lhsItems), .loaded(let rhsItems)):
return lhsItems.map { $0.itemID } == rhsItems.map { $0.itemID }
case (.loading(let lhsItems), .loading(let rhsItems)),
(.loaded(let lhsItems), .loaded(let rhsItems)):
return true// lhsItems == rhsItems
case (.error(let lhsError), .error(let rhsError)):
return lhsError == rhsError
default:
Expand Down
16 changes: 6 additions & 10 deletions WooCommerce/Classes/POS/Presentation/ItemListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ struct ItemListView: View {
// These cases are handled directly in the dashboard, we do not render
// a specific view within the ItemListView to handle them
EmptyView()
case .loading, .loaded:
listView(viewModel.items)
case .loading(let items), .loaded(let items):
listView(items)
}
}
.refreshable {
Expand Down Expand Up @@ -123,26 +123,22 @@ private extension ItemListView {
}

@ViewBuilder
func listView(_ items: [POSItem]) -> some View {
func listView(_ items: [any POSDisplayableItem]) -> some View {
ScrollView {
VStack {
if dynamicTypeSize.isAccessibilitySize, viewModel.shouldShowHeaderBanner {
bannerCardView
}
ForEach(items, id: \.productID) { item in
Button(action: {
viewModel.select(item)
}, label: {
ItemCardView(item: item)
})
ForEach(items, id: \.id) { item in
AnyView(item)
}
}
.padding(.bottom, floatingControlAreaSize.height)
.padding(.horizontal, Constants.itemListPadding)
.background(GeometryReader { proxy in
Color.clear
.onChange(of: proxy.frame(in: .global).maxY) { maxY in
if posModel.itemListState == .loading {
if case .loading = posModel.itemListState {
return
}
let viewHeight = UIScreen.main.bounds.height
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ struct PointOfSaleEntryPointView: View {
itemListViewModel: itemListViewModel,
posModel: posModel)
.environmentObject(posModalManager)
.environmentObject(posModel)
.onAppear {
onPointOfSaleModeActiveStateChange(true)
}
Expand Down
10 changes: 1 addition & 9 deletions WooCommerce/Classes/POS/ViewModels/ItemListViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@ import protocol Yosemite.POSItem
final class ItemListViewModel: ItemListViewModelProtocol {
let posModel: PointOfSaleAggregateModel

var items: [POSItem] {
posModel.allItems
}

@Published private(set) var isHeaderBannerDismissed: Bool = false
@Published var showSimpleProductsModal: Bool = false

Expand All @@ -19,17 +15,13 @@ final class ItemListViewModel: ItemListViewModelProtocol {
if UserDefaults.standard.bool(forKey: BannerState.isSimpleProductsOnlyBannerDismissedKey) == true {
return false
}
return !isHeaderBannerDismissed && items.isNotEmpty
return !isHeaderBannerDismissed && posModel.itemListState != .empty
}

init(posModel: PointOfSaleAggregateModel) {
self.posModel = posModel
}

func select(_ item: POSItem) {
posModel.selected(item: item)
}

@MainActor
func loadNextItems() async {
// TODO: Optimize API calls. gh-14186
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@ import Foundation
import protocol Yosemite.POSItem

protocol ItemListViewModelProtocol: ObservableObject {
var items: [POSItem] { get }
var isHeaderBannerDismissed: Bool { get }
var shouldShowHeaderBanner: Bool { get }

func select(_ item: POSItem)
func loadNextItems() async
func dismissBanner()
}
4 changes: 4 additions & 0 deletions WooCommerce/WooCommerce.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -831,6 +831,7 @@
20B7E3152CD0DDDA007FD997 /* PointOfSaleOrderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B7E3142CD0DDDA007FD997 /* PointOfSaleOrderState.swift */; };
20B7E3172CD100DC007FD997 /* PointOfSalePaymentState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B7E3162CD100DC007FD997 /* PointOfSalePaymentState.swift */; };
20B7E3192CD13CE3007FD997 /* PointOfSaleItemListState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B7E3182CD13CE3007FD997 /* PointOfSaleItemListState.swift */; };
20B7E3512CD3DAB5007FD997 /* POSDisplayableItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20B7E3502CD3DAB5007FD997 /* POSDisplayableItem.swift */; };
20BBD62C2B3060A300A903F6 /* AddOrderComponentsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BBD62B2B3060A300A903F6 /* AddOrderComponentsSection.swift */; };
20BCF6EE2B0E478B00954840 /* WooPaymentsDepositsOverviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BCF6ED2B0E478B00954840 /* WooPaymentsDepositsOverviewViewModel.swift */; };
20BCF6F02B0E48CC00954840 /* WooPaymentsDepositsOverviewViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BCF6EF2B0E48CC00954840 /* WooPaymentsDepositsOverviewViewModelTests.swift */; };
Expand Down Expand Up @@ -3922,6 +3923,7 @@
20B7E3142CD0DDDA007FD997 /* PointOfSaleOrderState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleOrderState.swift; sourceTree = "<group>"; };
20B7E3162CD100DC007FD997 /* PointOfSalePaymentState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSalePaymentState.swift; sourceTree = "<group>"; };
20B7E3182CD13CE3007FD997 /* PointOfSaleItemListState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PointOfSaleItemListState.swift; sourceTree = "<group>"; };
20B7E3502CD3DAB5007FD997 /* POSDisplayableItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = POSDisplayableItem.swift; sourceTree = "<group>"; };
20BBD62B2B3060A300A903F6 /* AddOrderComponentsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddOrderComponentsSection.swift; sourceTree = "<group>"; };
20BCF6ED2B0E478B00954840 /* WooPaymentsDepositsOverviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsDepositsOverviewViewModel.swift; sourceTree = "<group>"; };
20BCF6EF2B0E48CC00954840 /* WooPaymentsDepositsOverviewViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WooPaymentsDepositsOverviewViewModelTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -7101,6 +7103,7 @@
026826972BF59D9E0036F959 /* Utils */,
026826A12BF59DED0036F959 /* Presentation */,
20B7E3122CCFFEB4007FD997 /* PointOfSaleAggregateModel.swift */,
20B7E3502CD3DAB5007FD997 /* POSDisplayableItem.swift */,
20B7E3182CD13CE3007FD997 /* PointOfSaleItemListState.swift */,
20B7E3162CD100DC007FD997 /* PointOfSalePaymentState.swift */,
20B7E3142CD0DDDA007FD997 /* PointOfSaleOrderState.swift */,
Expand Down Expand Up @@ -14731,6 +14734,7 @@
31FE28C225E6D338003519F2 /* LearnMoreTableViewCell.swift in Sources */,
02D45647231CB1FB008CF0A9 /* UIImage+Dot.swift in Sources */,
680BA59A2A4C377900F5559D /* UpgradeViewState.swift in Sources */,
20B7E3512CD3DAB5007FD997 /* POSDisplayableItem.swift in Sources */,
E11228BE2707267F004E9F2D /* CardPresentModalUpdateFailedNonRetryable.swift in Sources */,
86DE68822B4BA47A00B437A6 /* BlazeAdDestinationSettingViewModel.swift in Sources */,
EEC5E01129A70CC300416CAC /* StoreSetupProgressView.swift in Sources */,
Expand Down
31 changes: 21 additions & 10 deletions Yosemite/Yosemite/PointOfSale/POSProduct.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
struct POSProduct: POSItem {
let itemID: UUID
let productID: Int64
let name: String
let price: String
let formattedPrice: String
let itemCategories: [String]
var productImageSource: String?
let productType: ProductType
public struct POSProduct: POSItem, Equatable {
public let itemID: UUID
public let productID: Int64
public let name: String
public let price: String
public let formattedPrice: String
public let itemCategories: [String]
public var productImageSource: String?
public let productType: ProductType

init(itemID: UUID,
public init(itemID: UUID,
productID: Int64,
name: String,
price: String,
Expand All @@ -25,4 +25,15 @@ struct POSProduct: POSItem {
self.productImageSource = productImageSource
self.productType = productType
}

public static func ==(lhs: POSProduct, rhs: POSProduct) -> Bool {
return lhs.itemID == rhs.itemID &&
lhs.productID == rhs.productID &&
lhs.name == rhs.name &&
lhs.price == rhs.price &&
lhs.formattedPrice == rhs.formattedPrice &&
lhs.itemCategories == rhs.itemCategories &&
lhs.productImageSource == rhs.productImageSource &&
lhs.productType == rhs.productType
}
}