Skip to content

[Woo POS] Favourite products in POS #15533

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

Draft
wants to merge 4 commits into
base: trunk
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
17 changes: 17 additions & 0 deletions Networking/Networking/Remote/ProductsRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,23 @@ public final class ProductsRemote: Remote, ProductsRemoteProtocol {
parameters: parameters)
}

public func loadFavoriteProductsForPointOfSale(for siteID: Int64,
productIDs: [Int64],
productTypes: [ProductType] = [.simple],
pageNumber: Int = 1) async throws -> PagedItems<POSProduct> {
var parameters = pointOfSaleProductFetchParameters(
pageNumber: pageNumber,
productTypes: productTypes)

// Add the include parameter for favorite product IDs
parameters[ParameterKey.include] = productIDs.map { String($0) }.joined(separator: ",")

return try await makePagedPointOfSaleProductsRequest(
for: siteID,
pageNumber: pageNumber,
parameters: parameters)
}

private func pointOfSaleProductFetchParameters(pageNumber: Int,
productTypes: [ProductType]) -> [String: String] {
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ protocol PointOfSaleCouponsControllerProtocol: PointOfSaleItemsControllerProtoco

@available(iOS 17.0, *)
@Observable final class PointOfSaleCouponsController: PointOfSaleCouponsControllerProtocol {
func setFavoritesFilter(productIDs: [Int64], baseItem: ItemListBaseItem) async {}

func clearFavoritesFilter(baseItem: ItemListBaseItem) async {}

var itemsViewState: ItemsViewState = ItemsViewState(containerState: .content,
itemsStack: ItemsStackState(root: .loading([]),
itemStates: [:]))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ protocol PointOfSaleItemsControllerProtocol {
func refreshItems(base: ItemListBaseItem) async
/// Loads the next page of items for a given base item.
func loadNextItems(base: ItemListBaseItem) async

func setFavoritesFilter(productIDs: [Int64], baseItem: ItemListBaseItem) async
func clearFavoritesFilter(baseItem: ItemListBaseItem) async
}

@available(iOS 17.0, *)
Expand Down Expand Up @@ -82,6 +85,20 @@ protocol PointOfSaleSearchingItemsControllerProtocol: PointOfSaleItemsController
await loadFirstPage(base: baseItem)
}

@MainActor
func setFavoritesFilter(productIDs: [Int64], baseItem: ItemListBaseItem) async {
fetchStrategy = itemFetchStrategyFactory.favoritesStrategy(productIDs: productIDs)
setSearchingState(base: baseItem)
await loadFirstPage(base: baseItem)
}

@MainActor
func clearFavoritesFilter(baseItem: ItemListBaseItem) async {
fetchStrategy = itemFetchStrategyFactory.defaultStrategy
setSearchingState(base: baseItem)
await loadFirstPage(base: baseItem)
}

@MainActor
private func loadFirstPage(base: ItemListBaseItem) async {
switch base {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import enum Yosemite.POSItem
import enum Yosemite.SystemStatusAction
import protocol Yosemite.POSSearchHistoryProviding
import enum Yosemite.POSItemType
import protocol Yosemite.POSFavouriteProductsServiceProtocol

@available(iOS 17.0, *)
protocol PointOfSaleAggregateModelProtocol {
Expand All @@ -29,6 +30,7 @@ protocol PointOfSaleAggregateModelProtocol {
var purchasableItemsController: PointOfSaleItemsControllerProtocol { get }
var purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol { get }
var couponsController: PointOfSaleCouponsControllerProtocol { get }
var favoriteProductsService: POSFavouriteProductsServiceProtocol { get }

var cart: Cart { get }
func addToCart(_ item: POSItem)
Expand Down Expand Up @@ -65,6 +67,7 @@ protocol PointOfSaleAggregateModelProtocol {
let purchasableItemsController: PointOfSaleItemsControllerProtocol
let purchasableItemsSearchController: PointOfSaleSearchingItemsControllerProtocol
let couponsController: PointOfSaleCouponsControllerProtocol
let favoriteProductsService: POSFavouriteProductsServiceProtocol

private let cardPresentPaymentService: CardPresentPaymentFacade
private let orderController: PointOfSaleOrderControllerProtocol
Expand All @@ -85,6 +88,7 @@ protocol PointOfSaleAggregateModelProtocol {
analytics: Analytics = ServiceLocator.analytics,
collectOrderPaymentAnalyticsTracker: POSCollectOrderPaymentAnalyticsTracking,
searchHistoryService: POSSearchHistoryProviding,
favoriteProductsService: POSFavouriteProductsServiceProtocol,
paymentState: PointOfSalePaymentState = .card(.idle)) {
self.purchasableItemsController = itemsController
self.purchasableItemsSearchController = purchasableItemsSearchController
Expand All @@ -94,6 +98,7 @@ protocol PointOfSaleAggregateModelProtocol {
self.analytics = analytics
self.collectOrderPaymentAnalyticsTracker = collectOrderPaymentAnalyticsTracker
self.searchHistoryService = searchHistoryService
self.favoriteProductsService = favoriteProductsService
self.paymentState = paymentState
publishCardReaderConnectionStatus()
publishPaymentMessages()
Expand Down
41 changes: 19 additions & 22 deletions WooCommerce/Classes/POS/Presentation/Item Selector/ItemList.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,31 +148,28 @@ private struct ItemListRow: View {
}, label: {
SimpleProductCardView(product: product)
})

case let .variableParentProduct(parentProduct):
if #available(iOS 18.0, *) {
NavigationLink(value: item) {
ParentProductCardView(name: parentProduct.name,
imageSource: parentProduct.productImageSource,
detailText: Localization.variationsAvailable)
HStack {
if #available(iOS 18.0, *) {
NavigationLink(value: item) {
ParentProductCardView(name: parentProduct.name,
imageSource: parentProduct.productImageSource,
detailText: Localization.variationsAvailable,
productID: parentProduct.productID)
}
} else {
Button(action: {
activeNavigationItem = item
}, label: {
ParentProductCardView(name: parentProduct.name,
imageSource: parentProduct.productImageSource,
detailText: Localization.variationsAvailable,
productID: parentProduct.productID)
})
}
} else {
// Use a button to trigger navigation programmatically on iOS 17.

// We should drop this when we leave iOS 17.0 behind, but due to memory leaks caused by NavigationStack.
// we still have to use the NavigationView approach here.
// When we remove it, itemsStack will no longer be a dependency of ItemList

// Note that we don't use Navigation Link as this row can be redrawn if the dynamic type size
// is changed enough to push it offscreen. When that happens while viewing a child list,
// the navigation gets cancelled and the user is sent back to the root.
Button(action: {
activeNavigationItem = item
}, label: {
ParentProductCardView(name: parentProduct.name,
imageSource: parentProduct.productImageSource,
detailText: Localization.variationsAvailable)
})
}

case let .variation(variation):
Button(action: {
itemActionHandler.handleTap(item)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import SwiftUI

/// Displays a card for a parent product in POS.
@available(iOS 17.0, *)
struct ParentProductCardView: View {
private let name: String
private let imageSource: String?
private let detailText: String
private let productID: Int64
@Environment(PointOfSaleAggregateModel.self) private var posModel
@State private var isFavorite: Bool = false

@ScaledMetric private var scale: CGFloat = 1.0

private var dimension: CGFloat {
min(Constants.productCardSize * scale, Constants.maximumProductCardSize)
}

init(name: String, imageSource: String?, detailText: String) {
init(name: String, imageSource: String?, detailText: String, productID: Int64) {
self.name = name
self.imageSource = imageSource
self.detailText = detailText
self.productID = productID
}

var body: some View {
Expand All @@ -39,19 +44,45 @@ struct ParentProductCardView: View {
.padding(.horizontal, Constants.horizontalTextPadding * (1 / scale))
.padding(.vertical, Constants.verticalTextPadding * (1 / scale))
Spacer()

Button(action: {
if isFavorite {
posModel.favoriteProductsService.removeFromFavorite(productID: productID)
} else {
posModel.favoriteProductsService.markAsFavorite(productID: productID)
}
isFavorite.toggle()
}) {
Circle()
.foregroundColor(.posSurfaceContainerLow)
.overlay {
Image(systemName: isFavorite ? "star.fill" : "star")
.font(.posButtonSymbolSmall)
.foregroundColor(.posOnSurface)
.dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize)
}
.frame(width: Constants.favoriteButtonSize, height: Constants.favoriteButtonSize)
}
.padding(.trailing, Constants.horizontalTextPadding * (1 / scale))
}
.frame(maxWidth: .infinity, idealHeight: dimension)
.background(Constants.backgroundColor)
.posItemCardBorderStyles()
.task {
isFavorite = await posModel.favoriteProductsService.isFavorite(productID: productID)
}
}
}

private typealias Constants = PointOfSaleItemListCardConstants

#if DEBUG
@available(iOS 17.0, *)
#Preview {
ParentProductCardView(name: "Parent product",
imageSource: nil,
detailText: "Detail text")
detailText: "Detail text",
productID: 1)
.environment(POSPreviewHelpers.makePreviewAggregateModel())
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ enum PointOfSaleItemListCardConstants {
static let backgroundColor: Color = .posSurfaceContainerLowest
static let titleColor: Color = .posOnSurface
static let detailColor: Color = .posOnSurfaceVariantHighest
static let favoriteButtonSize: CGFloat = 32
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import struct Yosemite.POSSimpleProduct
import SwiftUI

@available(iOS 17.0, *)
struct SimpleProductCardView: View {
private let product: POSSimpleProduct
@Environment(PointOfSaleAggregateModel.self) private var posModel
@State private var isFavorite: Bool = false

@ScaledMetric private var scale: CGFloat = 1.0
@Environment(\.dynamicTypeSize) var dynamicTypeSize
Expand Down Expand Up @@ -36,23 +39,49 @@ struct SimpleProductCardView: View {
.padding(.horizontal, Constants.horizontalTextPadding * (1 / scale))
.padding(.vertical, Constants.verticalTextPadding * (1 / scale))
Spacer()

Button(action: {
if isFavorite {
posModel.favoriteProductsService.removeFromFavorite(productID: product.productID)
} else {
posModel.favoriteProductsService.markAsFavorite(productID: product.productID)
}
isFavorite.toggle()
}) {
Circle()
.foregroundColor(.posSurfaceContainerLow)
.overlay {
Image(systemName: isFavorite ? "star.fill" : "star")
.font(.posButtonSymbolSmall)
.foregroundColor(.posOnSurface)
.dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize)
}
.frame(width: Constants.favoriteButtonSize, height: Constants.favoriteButtonSize)
}
.padding(.trailing, Constants.horizontalTextPadding * (1 / scale))
}
.frame(maxWidth: .infinity, idealHeight: dimension)
.background(Constants.backgroundColor)
.posItemCardBorderStyles()
.task {
isFavorite = await posModel.favoriteProductsService.isFavorite(productID: product.productID)
}
}
}

@available(iOS 17.0, *)
private extension SimpleProductCardView {
typealias Constants = PointOfSaleItemListCardConstants
}

#if DEBUG
@available(iOS 17.0, *)
#Preview {
SimpleProductCardView(product: POSSimpleProduct(id: UUID(),
name: "Product name",
formattedPrice: "$3.00",
productID: 123,
price: "3.00"))
.environment(POSPreviewHelpers.makePreviewAggregateModel())
}
#endif
66 changes: 43 additions & 23 deletions WooCommerce/Classes/POS/Presentation/ItemListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ struct ItemListView: View {
@Environment(PointOfSaleAggregateModel.self) private var posModel

@State private var showSimpleProductsModal: Bool = false
@State private var showFavoritesOnly: Bool = false

@Binding var selectedItemListType: ItemListType
@Binding var searchTerm: String
Expand Down Expand Up @@ -168,34 +169,53 @@ private extension ItemListView {
}
}

POSPageHeaderActionButton(systemName: "magnifyingglass") {
selectedItemListType = .products(search: true)
isSearchFieldFocused = true
if case .products = selectedItemListType, !shouldShowSearchField {
POSPageHeaderActionButton(systemName: "magnifyingglass") {
selectedItemListType = .products(search: true)
isSearchFieldFocused = true
}
.renderedIf(!shouldShowSearchField)
.transition(.opacity.combined(with: .scale))

POSPageHeaderActionButton {
showFavoritesOnly.toggle()
Task { @MainActor in
if showFavoritesOnly {
let favoriteProductIDs = await posModel.favoriteProductsService.favoriteProductIDs()
await posModel.purchasableItemsController.setFavoritesFilter(productIDs: favoriteProductIDs, baseItem: .root)
} else {
await posModel.purchasableItemsController.clearFavoritesFilter(baseItem: .root)
}
}
} label: {
Image(systemName: showFavoritesOnly ? "star.fill" : "star")
.foregroundColor(.posOnSurface)
.font(.posButtonSymbolLarge)
.dynamicTypeSize(...POSHeaderLayoutConstants.maximumDynamicTypeSize)
}
}
.renderedIf(!shouldShowSearchField)
.transition(.opacity.combined(with: .scale))
}

if shouldShowCoupons {
POSPageHeaderActionButton(systemName: "plus") {
ServiceLocator.analytics.track(.pointOfSaleCouponsCreateTapped)
showCouponCreationModal = true
if shouldShowCoupons {
POSPageHeaderActionButton(systemName: "plus") {
ServiceLocator.analytics.track(.pointOfSaleCouponsCreateTapped)
showCouponCreationModal = true
}
.renderedIf(isAddingCouponAllowed)
.transition(.opacity.combined(with: .scale))
}
.renderedIf(isAddingCouponAllowed)

Button(action: {
ServiceLocator.analytics.track(.pointOfSaleSimpleProductsExplanationDialogShown)
showSimpleProductsModal = true
}, label: {
Text(Image(systemName: "info.circle"))
.font(.posButtonSymbolLarge)
.foregroundStyle(Color.posOnSurface)
.padding(Constants.infoIconInset)
})
.renderedIf(!shouldShowHeaderBanner && !shouldShowCoupons)
.transition(.opacity.combined(with: .scale))
}

Button(action: {
ServiceLocator.analytics.track(.pointOfSaleSimpleProductsExplanationDialogShown)
showSimpleProductsModal = true
}, label: {
Text(Image(systemName: "info.circle"))
.font(.posButtonSymbolLarge)
.foregroundStyle(Color.posOnSurface)
.padding(Constants.infoIconInset)
})
.renderedIf(!shouldShowHeaderBanner && !shouldShowCoupons)
.transition(.opacity.combined(with: .scale))
}
})
if !dynamicTypeSize.isAccessibilitySize, shouldShowHeaderBanner {
Expand Down
Loading