Skip to content

Commit b4f2bf1

Browse files
authored
Merge pull request #5893 from woocommerce/issue/5847-variation-list-view
Order Creation: Add product variation list view
2 parents d6b4570 + cf13799 commit b4f2bf1

File tree

5 files changed

+193
-38
lines changed

5 files changed

+193
-38
lines changed

WooCommerce/Classes/ViewRelated/Orders/Order Creation/ProductsSection/AddProductToOrder.swift

Lines changed: 2 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,16 @@ struct AddProductToOrder: View {
1616
Group {
1717
switch viewModel.syncStatus {
1818
case .results:
19-
List {
19+
InfiniteScrollList(isLoading: viewModel.shouldShowScrollIndicator,
20+
loadAction: viewModel.syncNextPage) {
2021
ForEach(viewModel.productRows) { rowViewModel in
2122
ProductRow(viewModel: rowViewModel)
2223
.onTapGesture {
2324
viewModel.selectProduct(rowViewModel.productOrVariationID)
2425
isPresented.toggle()
2526
}
2627
}
27-
28-
InfiniteScrollIndicator(showContent: viewModel.shouldShowScrollIndicator)
29-
.onAppear {
30-
viewModel.syncNextPage()
31-
}
3228
}
33-
.listStyle(PlainListStyle())
3429
case .empty:
3530
EmptyState(title: Localization.emptyStateMessage, image: .emptyProductsTabImage)
3631
.frame(maxHeight: .infinity)
@@ -64,30 +59,6 @@ struct AddProductToOrder: View {
6459
}
6560
}
6661

67-
private struct InfiniteScrollIndicator: View {
68-
69-
let showContent: Bool
70-
71-
var body: some View {
72-
if #available(iOS 15.0, *) {
73-
createProgressView()
74-
.listRowSeparator(.hidden, edges: .bottom)
75-
} else {
76-
createProgressView()
77-
}
78-
}
79-
80-
@ViewBuilder func createProgressView() -> some View {
81-
ProgressView()
82-
.frame(maxWidth: .infinity, alignment: .center)
83-
.listRowInsets(EdgeInsets())
84-
.listRowBackground(Color(.listBackground))
85-
.if(!showContent) { progressView in
86-
progressView.hidden() // Hidden but still in view hierarchy so `onAppear` will trigger sync when needed
87-
}
88-
}
89-
}
90-
9162
private extension AddProductToOrder {
9263
enum Localization {
9364
static let title = NSLocalizedString("Add Product", comment: "Title for the screen to add a product to an order")
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import SwiftUI
2+
3+
/// View showing a list of product variations to add to an order.
4+
///
5+
struct AddProductVariationToOrder: View {
6+
/// Defines whether the view is presented.
7+
///
8+
@Binding var isPresented: Bool
9+
10+
/// View model to drive the view.
11+
///
12+
@ObservedObject var viewModel: AddProductVariationToOrderViewModel
13+
14+
var body: some View {
15+
Group {
16+
switch viewModel.syncStatus {
17+
case .results:
18+
InfiniteScrollList(isLoading: viewModel.shouldShowScrollIndicator,
19+
loadAction: viewModel.syncNextPage) {
20+
ForEach(viewModel.productVariationRows) { rowViewModel in
21+
ProductRow(viewModel: rowViewModel)
22+
.onTapGesture {
23+
viewModel.selectVariation(rowViewModel.productOrVariationID)
24+
isPresented.toggle()
25+
}
26+
}
27+
}
28+
case .empty:
29+
EmptyState(title: Localization.emptyStateMessage, image: .emptyProductsTabImage)
30+
.frame(maxHeight: .infinity)
31+
case .firstPageSync:
32+
List(viewModel.ghostRows) { rowViewModel in
33+
ProductRow(viewModel: rowViewModel)
34+
.redacted(reason: .placeholder)
35+
.shimmering()
36+
}
37+
.listStyle(PlainListStyle())
38+
default:
39+
EmptyView()
40+
}
41+
}
42+
.background(Color(.listBackground).ignoresSafeArea())
43+
.ignoresSafeArea(.container, edges: .horizontal)
44+
.navigationTitle(viewModel.productName)
45+
.navigationBarTitleDisplayMode(.inline)
46+
.onAppear {
47+
viewModel.syncFirstPage()
48+
}
49+
}
50+
}
51+
52+
private extension AddProductVariationToOrder {
53+
enum Localization {
54+
static let emptyStateMessage = NSLocalizedString("No product variations found",
55+
comment: "Message displayed if there are no product variations for a product.")
56+
}
57+
}
58+
59+
struct AddProductVariationToOrder_Previews: PreviewProvider {
60+
static var previews: some View {
61+
let viewModel = AddProductVariationToOrderViewModel(siteID: 1, productID: 2, productName: "Monstera Plant", productAttributes: [])
62+
63+
AddProductVariationToOrder(isPresented: .constant(true), viewModel: viewModel)
64+
}
65+
}

WooCommerce/Classes/ViewRelated/Orders/Order Creation/ProductsSection/AddProductVariationToOrderViewModel.swift

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,17 @@ final class AddProductVariationToOrderViewModel: ObservableObject {
1414
///
1515
private let stores: StoresManager
1616

17-
/// The product whose variations are listed
17+
/// The ID of the parent variable product
1818
///
19-
private var product: Product
19+
private let productID: Int64
20+
21+
/// The name of the parent variable product
22+
///
23+
let productName: String
24+
25+
/// All attributes for variations of the parent variable product
26+
///
27+
private let productAttributes: [ProductAttribute]
2028

2129
/// All purchasable product variations for the product.
2230
///
@@ -27,7 +35,7 @@ final class AddProductVariationToOrderViewModel: ObservableObject {
2735
/// View models for each product variation row
2836
///
2937
var productVariationRows: [ProductRowViewModel] {
30-
productVariations.map { .init(productVariation: $0, allAttributes: product.attributesForVariations, canChangeQuantity: false) }
38+
productVariations.map { .init(productVariation: $0, allAttributes: productAttributes, canChangeQuantity: false) }
3139
}
3240

3341
// MARK: Sync & Storage properties
@@ -55,25 +63,41 @@ final class AddProductVariationToOrderViewModel: ObservableObject {
5563
/// Product Variations Results Controller.
5664
///
5765
private lazy var productVariationsResultsController: ResultsController<StorageProductVariation> = {
58-
let predicate = NSPredicate(format: "siteID == %lld AND productID == %lld", siteID, product.productID)
66+
let predicate = NSPredicate(format: "siteID == %lld AND productID == %lld", siteID, productID)
5967
let descriptor = NSSortDescriptor(keyPath: \StorageProductVariation.menuOrder, ascending: true)
6068
let resultsController = ResultsController<StorageProductVariation>(storageManager: storageManager, matching: predicate, sortedBy: [descriptor])
6169
return resultsController
6270
}()
6371

6472
init(siteID: Int64,
65-
product: Product,
73+
productID: Int64,
74+
productName: String,
75+
productAttributes: [ProductAttribute],
6676
storageManager: StorageManagerType = ServiceLocator.storageManager,
6777
stores: StoresManager = ServiceLocator.stores) {
6878
self.siteID = siteID
69-
self.product = product
79+
self.productID = productID
80+
self.productName = productName
81+
self.productAttributes = productAttributes
7082
self.storageManager = storageManager
7183
self.stores = stores
7284

7385
configureSyncingCoordinator()
7486
configureProductVariationsResultsController()
7587
}
7688

89+
convenience init(siteID: Int64,
90+
product: Product,
91+
storageManager: StorageManagerType = ServiceLocator.storageManager,
92+
stores: StoresManager = ServiceLocator.stores) {
93+
self.init(siteID: siteID,
94+
productID: product.productID,
95+
productName: product.name,
96+
productAttributes: product.attributesForVariations,
97+
storageManager: storageManager,
98+
stores: stores)
99+
}
100+
77101
/// Select a product variation to add to the order
78102
///
79103
func selectVariation(_ productID: Int64) {
@@ -88,7 +112,7 @@ extension AddProductVariationToOrderViewModel: SyncingCoordinatorDelegate {
88112
func sync(pageNumber: Int, pageSize: Int, reason: String? = nil, onCompletion: ((Bool) -> Void)?) {
89113
transitionToSyncingState()
90114
let action = ProductVariationAction.synchronizeProductVariations(siteID: siteID,
91-
productID: product.productID,
115+
productID: productID,
92116
pageNumber: pageNumber,
93117
pageSize: pageSize) { [weak self] error in
94118
guard let self = self else { return }
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import SwiftUI
2+
3+
/// A list that renders the provided list content with an infinite scroll indicator.
4+
///
5+
struct InfiniteScrollList<Content: View>: View {
6+
/// Content to render in the list.
7+
///
8+
private let listContent: Content
9+
10+
/// Whether the list is loading more content. Used to determine whether to show the infinite scroll indicator.
11+
///
12+
private let isLoading: Bool
13+
14+
/// Action to load more content.
15+
///
16+
private let loadAction: () -> Void
17+
18+
/// Creates a list with the provided content and an infinite scroll indicator.
19+
///
20+
/// - Parameters:
21+
/// - isLoading: Whether the list is loading more content. Used to determine whether to show the infinite scroll indicator.
22+
/// - loadAction: Action to load more content.
23+
/// - listContent: Content to render in the list.
24+
init(isLoading: Bool,
25+
loadAction: @escaping () -> Void,
26+
@ViewBuilder listContent: () -> Content) {
27+
self.listContent = listContent()
28+
self.isLoading = isLoading
29+
self.loadAction = loadAction
30+
}
31+
32+
var body: some View {
33+
List {
34+
listContent
35+
36+
InfiniteScrollIndicator(showContent: isLoading)
37+
.onAppear {
38+
loadAction()
39+
}
40+
}
41+
.listStyle(PlainListStyle())
42+
}
43+
}
44+
45+
private struct InfiniteScrollIndicator: View {
46+
47+
let showContent: Bool
48+
49+
var body: some View {
50+
if #available(iOS 15.0, *) {
51+
createProgressView()
52+
.listRowSeparator(.hidden, edges: .bottom)
53+
} else {
54+
createProgressView()
55+
}
56+
}
57+
58+
@ViewBuilder func createProgressView() -> some View {
59+
ProgressView()
60+
.frame(maxWidth: .infinity, alignment: .center)
61+
.listRowInsets(EdgeInsets())
62+
.listRowBackground(Color(.listBackground))
63+
.if(!showContent) { progressView in
64+
progressView.hidden() // Hidden but still in view hierarchy so `onAppear` will trigger the load action when needed
65+
}
66+
}
67+
}
68+
69+
struct InfiniteScrollList_Previews: PreviewProvider {
70+
static var previews: some View {
71+
InfiniteScrollList(isLoading: true, loadAction: {}) {
72+
ForEach((0..<6)) { index in
73+
Text("Item \(index)")
74+
}
75+
}
76+
.previewDisplayName("Infinite scroll list: Loading")
77+
.previewLayout(.sizeThatFits)
78+
79+
InfiniteScrollList(isLoading: false, loadAction: {}) {
80+
ForEach((0..<6)) { index in
81+
Text("Item \(index)")
82+
}
83+
}
84+
.previewDisplayName("Infinite scroll list: Loaded")
85+
.previewLayout(.sizeThatFits)
86+
}
87+
}

WooCommerce/WooCommerce.xcodeproj/project.pbxproj

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1180,6 +1180,8 @@
11801180
CCDC49ED24000533003166BA /* TestCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCDC49EC24000533003166BA /* TestCredentials.swift */; };
11811181
CCE4CD172667EBB100E09FD4 /* ShippingLabelPaymentMethodsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE4CD162667EBB100E09FD4 /* ShippingLabelPaymentMethodsViewModelTests.swift */; };
11821182
CCE4CD282669324300E09FD4 /* ShippingLabelPaymentMethodsTopBanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCE4CD272669324300E09FD4 /* ShippingLabelPaymentMethodsTopBanner.swift */; };
1183+
CCF87BBE279047BC00461C43 /* InfiniteScrollList.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF87BBD279047BC00461C43 /* InfiniteScrollList.swift */; };
1184+
CCF87BC02790582500461C43 /* AddProductVariationToOrder.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCF87BBF2790582400461C43 /* AddProductVariationToOrder.swift */; };
11831185
CCFC00B523E9BD1500157A78 /* ScreenshotCredentials.swift in Sources */ = {isa = PBXBuildFile; fileRef = CCFC00B423E9BD1500157A78 /* ScreenshotCredentials.swift */; };
11841186
CCFC00EE23E9BD5500157A78 /* oauth2_token.json in Resources */ = {isa = PBXBuildFile; fileRef = CCFC00BC23E9BD5500157A78 /* oauth2_token.json */; };
11851187
CCFC00EF23E9BD5500157A78 /* auth_options.json in Resources */ = {isa = PBXBuildFile; fileRef = CCFC00BD23E9BD5500157A78 /* auth_options.json */; };
@@ -2772,6 +2774,8 @@
27722774
CCDC49F224006130003166BA /* UITests.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; name = UITests.xctestplan; path = WooCommerceUITests/UITests.xctestplan; sourceTree = SOURCE_ROOT; };
27732775
CCE4CD162667EBB100E09FD4 /* ShippingLabelPaymentMethodsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaymentMethodsViewModelTests.swift; sourceTree = "<group>"; };
27742776
CCE4CD272669324300E09FD4 /* ShippingLabelPaymentMethodsTopBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShippingLabelPaymentMethodsTopBanner.swift; sourceTree = "<group>"; };
2777+
CCF87BBD279047BC00461C43 /* InfiniteScrollList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfiniteScrollList.swift; sourceTree = "<group>"; };
2778+
CCF87BBF2790582400461C43 /* AddProductVariationToOrder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddProductVariationToOrder.swift; sourceTree = "<group>"; };
27752779
CCFC00B423E9BD1500157A78 /* ScreenshotCredentials.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScreenshotCredentials.swift; sourceTree = "<group>"; };
27762780
CCFC00BC23E9BD5500157A78 /* oauth2_token.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = oauth2_token.json; sourceTree = "<group>"; };
27772781
CCFC00BD23E9BD5500157A78 /* auth_options.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = auth_options.json; sourceTree = "<group>"; };
@@ -4957,6 +4961,7 @@
49574961
AE6DBE3A2732CAAD00957E7A /* AdaptiveStack.swift */,
49584962
26B3EC632745916F0075EAE6 /* BindableTextField.swift */,
49594963
AEACCB6C2785FF4A000D01F0 /* NavigationRow.swift */,
4964+
CCF87BBD279047BC00461C43 /* InfiniteScrollList.swift */,
49604965
);
49614966
path = "SwiftUI Components";
49624967
sourceTree = "<group>";
@@ -6136,6 +6141,7 @@
61366141
children = (
61376142
CC53FB372755213900C4CA4F /* AddProductToOrder.swift */,
61386143
CC53FB3B2757EC7200C4CA4F /* AddProductToOrderViewModel.swift */,
6144+
CCF87BBF2790582400461C43 /* AddProductVariationToOrder.swift */,
61396145
CC13C0CA278E021300C0B5B5 /* AddProductVariationToOrderViewModel.swift */,
61406146
CC53FB3427551A6E00C4CA4F /* ProductRow.swift */,
61416147
CC53FB39275697B000C4CA4F /* ProductRowViewModel.swift */,
@@ -8146,6 +8152,7 @@
81468152
6832C7CA26DA5C4500BA4088 /* LabeledTextViewTableViewCell.swift in Sources */,
81478153
DE7842F726F2E9340030C792 /* UIViewController+Connectivity.swift in Sources */,
81488154
E1C47209267A1ECC00D06DA1 /* CrashLoggingStack.swift in Sources */,
8155+
CCF87BBE279047BC00461C43 /* InfiniteScrollList.swift in Sources */,
81498156
D85A3C5226C15DE200C0E026 /* InPersonPaymentsPluginNotSupportedVersionView.swift in Sources */,
81508157
CE583A0B2107937F00D73C1C /* TextViewTableViewCell.swift in Sources */,
81518158
45AE150224A23F03005AA948 /* ProductParentCategoriesViewController.swift in Sources */,
@@ -8596,6 +8603,7 @@
85968603
0211252825773F220075AD2A /* Models+Copiable.generated.swift in Sources */,
85978604
4596853F2540669900D17B90 /* DownloadableFileSource.swift in Sources */,
85988605
0279F0E4252DC9670098D7DE /* ProductVariationLoadUseCase.swift in Sources */,
8606+
CCF87BC02790582500461C43 /* AddProductVariationToOrder.swift in Sources */,
85998607
02CA63DC23D1ADD100BBF148 /* DeviceMediaLibraryPicker.swift in Sources */,
86008608
021A84E0257DFC2A00BC71D1 /* RefundShippingLabelViewController.swift in Sources */,
86018609
DE279BA826E9C8E3002BA963 /* ShippingLabelSinglePackage.swift in Sources */,

0 commit comments

Comments
 (0)