Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ protocol PointOfSaleOrderControllerProtocol {
var orderState: PointOfSaleInternalOrderState { get }

@discardableResult
func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error>
func syncOrder(for cart: Cart, retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error>
func sendReceipt(recipientEmail: String) async throws
func clearOrder()
func collectCashPayment() async throws
Expand Down Expand Up @@ -63,9 +63,9 @@ protocol PointOfSaleOrderControllerProtocol {
private var order: Order? = nil

@MainActor @discardableResult
func syncOrder(for cartItems: [CartItem],
func syncOrder(for cart: Cart,
retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error> {
let posCartItems = cartItems.map {
let posCartItems = cart.items.map {
POSCartItem(item: $0.item, quantity: Decimal($0.quantity))
}

Expand Down
65 changes: 65 additions & 0 deletions WooCommerce/Classes/POS/Models/Cart.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation
import protocol Yosemite.POSOrderableItem
import enum Yosemite.POSItem

struct Cart {
var items: [CartProductItem] = []
Copy link
Contributor

Choose a reason for hiding this comment

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

For the Cart struct, should we update the name of items to products since we're starting to be more specific about the type?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Good question. I'm confused about this as well 😀 I chose this name only because we refer to products as items within the Order object. But then it's a how to call the type. Maybe I should rename CardProductItem to CartItem again, and remove the protocol CartItem since its value is questionable. Or call this CartItemProtocol 🤔

image

var coupons: [CartCouponItem] = []
}

protocol CartItem: Identifiable {
var id: UUID { get }
}

struct CartProductItem: CartItem {
let id: UUID
let item: POSOrderableItem
let title: String
let subtitle: String?
let quantity: Int
}

struct CartCouponItem: CartItem {
let id: UUID
let code: String
}

// MARK: - Helper Methods

extension Cart {
mutating func add(_ posItem: POSItem) {
switch posItem {
case .simpleProduct(let simpleProduct):
let productItem = CartProductItem(id: UUID(), item: simpleProduct, title: simpleProduct.name, subtitle: nil, quantity: 1)
items.insert(productItem, at: items.startIndex)
case .variation(let variation):
let productItem = CartProductItem(id: UUID(), item: variation, title: variation.parentProductName, subtitle: variation.name, quantity: 1)
items.insert(productItem, at: items.startIndex)
case .variableParentProduct:
return
case .coupon(let coupon):
let couponItem = CartCouponItem(id: UUID(), code: coupon.code)
coupons.insert(couponItem, at: items.startIndex)
}
}

mutating func remove(_ cartItem: any CartItem) {
switch cartItem {
case _ as CartProductItem:
items.removeAll { $0.id == cartItem.id }
case _ as CartCouponItem:
coupons.removeAll { $0.id == cartItem.id }
default:
break
}
}

mutating func removeAll() {
items.removeAll()
coupons.removeAll()
}

var isEmpty: Bool {
items.isEmpty && coupons.isEmpty
}
}
10 changes: 0 additions & 10 deletions WooCommerce/Classes/POS/Models/CartItem.swift

This file was deleted.

34 changes: 8 additions & 26 deletions WooCommerce/Classes/POS/Models/PointOfSaleAggregateModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import protocol Yosemite.POSOrderableItem
import protocol WooFoundation.Analytics
import struct Yosemite.Order
import struct Yosemite.OrderItem
import struct Yosemite.POSCartItem
import struct Yosemite.POSCoupon
import enum Yosemite.POSItem
import enum Yosemite.SystemStatusAction

Expand All @@ -28,9 +28,9 @@ protocol PointOfSaleAggregateModelProtocol {
func loadItems(base: ItemListBaseItem) async
func loadNextItems(base: ItemListBaseItem) async

var cart: [CartItem] { get }
var cart: Cart { get }
func addToCart(_ item: POSItem)
func remove(cartItem: CartItem)
func remove(cartItem: any CartItem)
func removeAllItemsFromCart()
func addMoreToCart()
func startNewCart()
Expand All @@ -54,7 +54,7 @@ protocol PointOfSaleAggregateModelProtocol {

var itemsViewState: ItemsViewState { itemsController.itemsViewState }

private(set) var cart: [CartItem] = []
private(set) var cart: Cart = .init()

var orderState: PointOfSaleOrderState { orderController.orderState.externalState }
private var internalOrderState: PointOfSaleInternalOrderState { orderController.orderState }
Expand Down Expand Up @@ -108,33 +108,15 @@ extension PointOfSaleAggregateModel {
}
}

// MARK: - Cart

private extension POSItem {
var cartItem: CartItem? {
switch self {
case .simpleProduct(let simpleProduct):
return CartItem(id: UUID(), item: simpleProduct, title: simpleProduct.name, subtitle: nil, quantity: 1)
case .variation(let variation):
return CartItem(id: UUID(), item: variation, title: variation.parentProductName, subtitle: variation.name, quantity: 1)
case .variableParentProduct:
return nil
case .coupon:
return nil
}
}
}

@available(iOS 17.0, *)
extension PointOfSaleAggregateModel {
func addToCart(_ item: POSItem) {
trackCustomerInteractionStarted()
guard let cartItem = item.cartItem else { return }
cart.insert(cartItem, at: cart.startIndex)
cart.add(item)
}

func remove(cartItem: CartItem) {
cart.removeAll(where: { $0.id == cartItem.id } )
func remove(cartItem: any CartItem) {
cart.remove(cartItem)
}

func removeAllItemsFromCart() {
Expand Down Expand Up @@ -164,7 +146,7 @@ private extension PointOfSaleAggregateModel {
func trackCustomerInteractionStarted() {
// At the moment we're assumming that an interaction starts simply when the cart is zero
// but a more complex logic will be needed for other cases
if cart.count == 0 {
if cart.isEmpty {
collectOrderPaymentAnalyticsTracker.trackCustomerInteractionStarted()
}
}
Expand Down
30 changes: 23 additions & 7 deletions WooCommerce/Classes/POS/Presentation/CartView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ struct CartView: View {
@State private var offSetPosition: CGFloat = 0.0
private var coordinateSpace: CoordinateSpace = .named(Constants.scrollViewCoordinateSpaceIdentifier)
private var shouldApplyHeaderBottomShadow: Bool {
posModel.cart.isNotEmpty && offSetPosition < 0
!posModel.cart.isEmpty && offSetPosition < 0
Copy link
Contributor

Choose a reason for hiding this comment

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

Just curious: Do you find ! + isEmpty more readable than .isNotEmpty? Or the change is just because we reach for cart.isEmpty as a product AND coupons check?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just curious: Do you find ! + isEmpty more readable than .isNotEmpty

No. There's no difference for me. I can declare .isNotEmpty on aCart struct, and keep .isNotEmpty.

}

@State private var shouldShowItemImages: Bool = false
Expand All @@ -22,7 +22,7 @@ struct CartView: View {
backButtonConfiguration: backButtonConfiguration,
trailingContent: {
DynamicHStack(horizontalAlignment: .trailing, verticalAlignment: .center, spacing: Constants.cartHeaderElementSpacing) {
if let itemsInCartLabel = viewHelper.itemsInCartLabel(for: posModel.cart.count) {
if let itemsInCartLabel = viewHelper.itemsInCartLabel(for: posModel.cart.items.count) {
Text(itemsInCartLabel)
.font(Constants.itemsFont)
.lineLimit(1)
Expand All @@ -43,11 +43,26 @@ struct CartView: View {
})
.if(shouldApplyHeaderBottomShadow, transform: { $0.applyBottomShadow(backgroundColor: backgroundColor) })

if posModel.cart.isNotEmpty {
if !posModel.cart.isEmpty {
ScrollViewReader { proxy in
ScrollView {
VStack(spacing: Constants.cartItemSpacing) {
ForEach(posModel.cart, id: \.id) { cartItem in

/// WIP: Behind the feature flag
if posModel.cart.coupons.isNotEmpty {
ForEach(posModel.cart.coupons, id: \.id) { couponItem in
CouponRowView(couponItem: couponItem,
onItemRemoveTapped: posModel.orderStage == .building ? {
posModel.remove(cartItem: couponItem)
} : nil)
.id(couponItem.id)
.transition(.opacity)
}

Spacer(minLength: 64)
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we make this section more explicit? Something like CouponsCartSectionView and declare a shouldRenderCouponsSection variable? I have no strong opinion about it, mostly for clarity when scanning the file and in case we forget about the feature flag or the internal comment.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

👍 Of course, I can. For now it's all dummy UI since I don't know how it will look. However, we can make the code tidy.


ForEach(posModel.cart.items, id: \.id) { cartItem in
ItemRowView(cartItem: cartItem,
showImage: $shouldShowItemImages,
onItemRemoveTapped: posModel.orderStage == .building ? {
Expand All @@ -58,7 +73,8 @@ struct CartView: View {
.transition(.opacity)
}
}
.animation(Constants.cartAnimation, value: posModel.cart.map(\.id))
.animation(Constants.cartAnimation, value: posModel.cart.items.map(\.id))
.animation(Constants.cartAnimation, value: posModel.cart.coupons.map(\.id))
.background(GeometryReader { geometry in
Color.clear.preference(key: ScrollOffSetPreferenceKey.self,
value: geometry.frame(in: coordinateSpace).origin.y)
Expand All @@ -81,7 +97,7 @@ struct CartView: View {
.renderedIf(posModel.orderStage == .finalizing)
}
.coordinateSpace(name: Constants.scrollViewCoordinateSpaceIdentifier)
.onChange(of: posModel.cart.first?.id) { itemToScrollTo in
.onChange(of: posModel.cart.items.first?.id) { itemToScrollTo in
if posModel.orderStage == .building {
withAnimation {
proxy.scrollTo(itemToScrollTo)
Expand Down Expand Up @@ -268,7 +284,7 @@ private extension CartView {
@available(iOS 17.0, *)
private extension CartView {
func trackCheckoutTapped() {
let itemsInCart = posModel.cart.count
let itemsInCart = posModel.cart.items.count
ServiceLocator.analytics.track(event: .PointOfSale.checkoutTapped(itemsInCart))
}
}
Expand Down
65 changes: 65 additions & 0 deletions WooCommerce/Classes/POS/Presentation/CouponRowView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import SwiftUI

struct CouponRowView: View {
private let couponItem: CartCouponItem
private let onItemRemoveTapped: (() -> Void)?

@ScaledMetric private var scale: CGFloat = 1.0

init(couponItem: CartCouponItem, onItemRemoveTapped: (() -> Void)? = nil) {
self.couponItem = couponItem
self.onItemRemoveTapped = onItemRemoveTapped
}

var body: some View {
HStack(spacing: Constants.horizontalElementSpacing) {
Rectangle()
.foregroundColor(.posSurfaceDim)
.overlay {
Text(Image(systemName: "tag.square.fill"))
.font(.posButtonSymbolLarge)
.foregroundColor(.posOnSurfaceVariantLowest)
}
.frame(width: Constants.couponCardSize, height: Constants.couponCardSize)

VStack(alignment: .leading) {
Text(couponItem.code)
.foregroundColor(PointOfSaleItemListCardConstants.titleColor)
.font(Constants.itemTitleFont)
}
.frame(maxWidth: .infinity, alignment: .leading)

if let onItemRemoveTapped {
Button(action: {
onItemRemoveTapped()
}, label: {
Text(Image(systemName: "xmark.circle"))
.font(.posButtonSymbolMedium)
})
.foregroundColor(Color.posOnSurfaceVariantLowest)
}
}
.padding(.trailing, Constants.cardContentHorizontalPadding)
.frame(maxWidth: .infinity, idealHeight: Constants.couponCardSize * scale)
.background(Color.posSurfaceContainerLowest)
.posItemCardBorderStyles()
.padding(.horizontal, Constants.horizontalPadding)
}
}

private extension CouponRowView {
enum Constants {
static let couponCardSize: CGFloat = 96
static let horizontalPadding: CGFloat = POSPadding.medium
static let horizontalElementSpacing: CGFloat = POSSpacing.medium
static let cardContentHorizontalPadding: CGFloat = POSPadding.medium
static let itemTitleFont: POSFontStyle = .posBodySmallBold
}
}

#if DEBUG
@available(iOS 17.0, *)
#Preview(traits: .sizeThatFitsLayout) {
CouponRowView(couponItem: CartCouponItem(id: UUID(), code: "10-Discount")) {}
}
#endif
1 change: 0 additions & 1 deletion WooCommerce/Classes/POS/Presentation/ItemListView.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import SwiftUI
import enum Yosemite.POSItem
import protocol Yosemite.POSOrderableItem

@available(iOS 17.0, *)
struct ItemListView: View {
Expand Down
24 changes: 12 additions & 12 deletions WooCommerce/Classes/POS/Presentation/ItemRowView.swift
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import SwiftUI

struct ItemRowView: View {
private let cartItem: CartItem
private let cartItem: CartProductItem
private let onItemRemoveTapped: (() -> Void)?

@ScaledMetric private var scale: CGFloat = 1.0
Expand All @@ -11,7 +11,7 @@ struct ItemRowView: View {
min(Constants.productCardSize * scale, Constants.maximumProductCardSize)
}

init(cartItem: CartItem, showImage: Binding<Bool> = .constant(true), onItemRemoveTapped: (() -> Void)? = nil) {
init(cartItem: CartProductItem, showImage: Binding<Bool> = .constant(true), onItemRemoveTapped: (() -> Void)? = nil) {
self.cartItem = cartItem
self._showProductImage = showImage
self.onItemRemoveTapped = onItemRemoveTapped
Expand Down Expand Up @@ -94,21 +94,21 @@ private extension ItemRowView {
#if DEBUG
@available(iOS 17.0, *)
#Preview(traits: .sizeThatFitsLayout) {
ItemRowView(cartItem: CartItem(id: UUID(),
item: PointOfSalePreviewItemService().providePointOfSaleItem(),
title: "Item Title",
subtitle: "Item Subtitle",
quantity: 2),
ItemRowView(cartItem: CartProductItem(id: UUID(),
item: PointOfSalePreviewItemService().providePointOfSaleItem(),
title: "Item Title",
subtitle: "Item Subtitle",
quantity: 2),
onItemRemoveTapped: { })
}

@available(iOS 17.0, *)
#Preview(traits: .sizeThatFitsLayout) {
ItemRowView(cartItem: CartItem(id: UUID(),
item: PointOfSalePreviewItemService().providePointOfSaleItem(),
title: "Item Title",
subtitle: nil,
quantity: 2),
ItemRowView(cartItem: CartProductItem(id: UUID(),
item: PointOfSalePreviewItemService().providePointOfSaleItem(),
title: "Item Title",
subtitle: nil,
quantity: 2),
onItemRemoveTapped: { })
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class PointOfSalePreviewOrderController: PointOfSaleOrderControllerProtocol {
OrderFactory.newOrder(currency: .USD)
)

func syncOrder(for cartProducts: [CartItem], retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error> {
func syncOrder(for cart: Cart, retryHandler: @escaping () async -> Void) async -> Result<SyncOrderState, Error> {
return .success(.newOrder)
}

Expand Down
4 changes: 2 additions & 2 deletions WooCommerce/Classes/POS/ViewHelpers/CartViewHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ final class CartViewHelper {
return orderState.isSyncing
}

func shouldShowClearCartButton(cart: [CartItem], orderStage: PointOfSaleOrderStage) -> Bool {
cart.isNotEmpty && orderStage == .building
func shouldShowClearCartButton(cart: Cart, orderStage: PointOfSaleOrderStage) -> Bool {
!cart.isEmpty && orderStage == .building
}
}

Expand Down
Loading